This one costs money.
AI redoes the requirements↔tests map — about $5–$10 on Sonnet. The current map is replaced.
The plain ↺ is free.
Review
Closes #37.
A visit can now record the vet who attended it, and the vet is optional. You can pick a vet when booking or editing a visit, or leave it empty, since the vet is often unknown when the appointment is made. The owner's page, the all-visits screen and the chatbot's visit list now show each visit's vet. A visit without one reads "No vet"; that includes every visit created before this change. The data model is a nullable visits.vet_id (migration V4). The API adds vetId plus read-only vet first and last names to the visit payloads. A few demo seed visits get a vet so both states show up.
Two decisions deserve a reviewer's eye. First, editing a visit (PUT) replaces it whole, so a request without vetId clears the vet. That is what makes clearing the field persist, as the ticket requires, but a client that omits the field will also wipe the vet. Second, deleting a vet keeps their visits and sets the vet to none (ON DELETE SET NULL); refusing the delete would also be defensible. Still open: an unknown vetId returns 404, the same as an unknown pet type, rather than 400. The chatbot's create_visit can't choose a vet. The new Angular specs have only run in CI, since npm couldn't run in my session. Separately, this branch's CI cancels itself: the push and pull-request runs share one concurrency group, so CI on the fix commit only went green after a manual re-run. That workflow fix belongs on main.
Merge risk
🤖: 3 assumptions / : 9 open review issues · 4 auto fixed see in VS Code
Round I · while coding
Where the ticket was ambiguous: the reading the coder chose.
What I didn’t do: refuse deleting a vet who has visits
Why 60%: Ticket item 4 makes "no vet" a normal state, and before this change vet deletion always succeeded. Losing who attended is a real history loss, so a business owner could reasonably pick refusal.
4ALTER TABLE visits ADD COLUMN vet_id INT REFERENCES vets (id) ON DELETE SET NULL;
What I didn’t do: leave every seeded visit without a vet
Why 65%: The demo should show both states; ticket item 4 is about real legacy rows, not the demo dataset.
+142UPDATE visits SET vet_id = 1 -- James Carter
+143 WHERE pet_id = 3 AND visit_date IN (DATE '2026-05-03', DATE '2025-08-21'); -- Milton
+144
145INSERT INTO users (username, password, enabled) VALUES
What I didn’t do: "—" or "Not assigned"
Why 75%: Ticket item 4 rules out "Unknown" and blank; "No vet" says "has none" literally, and the same label serves as the combo placeholder.
5export const NO_VET = 'No vet';
Round II · code review
Left in the code, each with its reason. They stay open until you agree or disagree.
Reviewer: A client that omits vetId wipes the existing vet; "leave unchanged" cannot be told from "clear".
PUT replaces the whole visit, so a clear always sticks (ticket item 2); our form always sends vetId.
+85currentVisit.setVet(vetRepository.findByIdOrNull(visitDto.getVetId()));
Reviewer: A stale or bogus vetId fails the booking or edit with the generic "Not found!" 404, indistinguishable from a missing visit or pet.
same as unknown pet types, OwnerRestController.java:150; a 400 belongs in a codebase-wide change.
+20return id == null ? null : findById(id).orElseThrow();
Reviewer: Bookings made through the chatbot always have no vet.
"everywhere" covers display, which list_visits does; booking a vet stays a staff action.
55@Transactional
Reviewer: The error handler turns a failed GET /vets into [], so the combo finds no match and displays the placeholder.
model keeps the old vetId, so saving does not clear it; display-only, error path.
+43this.vetService.getVets().subscribe(vets => this.vetOptions = toVetOptions(vets));
Reviewer: Probing vetIds on booking reveals which vets exist.
GET /api/vets lists every vet already; nothing is disclosed.
+20return id == null ? null : findById(id).orElseThrow();
Reviewer: Visit DTOs now carry the vet's first and last name.
Visit endpoints are OWNER_ADMIN-only and vet names are already public via /api/vets.
+51visitDto.setVetId(vet.getId())
+52 .setVetFirstName(vet.getFirstName())
+53 .setVetLastName(vet.getLastName());
Reviewer: No check that the chosen vet may attend the visit.
choosing any vet is the feature; the ticket sets no eligibility rule.
192@WithSpan("book-visit")
Reviewer: The edit spec feeds onSubmit a hand-built null; if the combo emitted '' or 0 on clear, the old vet or a 404 would follow.
pick('') sets null, line 57; covered by the combo's own spec.
57this.value = index === '' ? null : this.valueOf(option);
Reviewer: A visit with vetId set but names missing would print "null null".
VisitMapper.java:51-53 sets id and both names together from one entity.
21return visit.vetId == null ? NO_VET : `${visit.vetFirstName} ${visit.vetLastName}`;
Round III · fixing the review
Fixed by the review in 137 fix commits: be762955, 811a2fdd, 0c3f5d70, ca6b33ed, 33977c2c, 5a4cf83f, 20e1df32, 51500ddc, 1044914d, 2f17c049, 1d6719fb, fe553439, 1114f3ba, d01ef08c, 9e0ef169, 863c54c7, 08b14f13, b32b9f11, a0254476, 6b4b63be, 44689ad3, 2090d785, ab8862c5, db57f1b4, 24af8d66, b6b53c65, 37aa569c, 10d7ced1, 1f12a64e, 99aefebe, 010682cf, 28f30dcc, c68428db, 316a8065, 4384ff57, d99781fb, 014a6f1e, ba74d040, f7ebe11c, e006175b, 846f8718, 286ac3de, cdfb429f, 5970eab8, a6baee9d, 39cbb618, 295c2c12, 75815871, aeb84cc9, 556ffafc, 9019ad8d, 4ee969ff, 07472b14, f1c7da79, 39346ec5, a931962a, 566aed82, 6c6c3c35, f39b3425, aa067ee6, c790590b, e5106841, 056347a0, 1bd1e444, a0fc27ec, 664c5b3b, 74fbab95, fe3c171f, ce0e3ce4, 6d5eb02f, 0084b881, 1c3773a6, 714dab3c, ec9fac90, 7ac6fd59, fdce1e71, fed20f04, f4aca5b4, 4cb349de, e27182cb, 640816dd, 7648a4c7, 2bc95b84, a994cf64, eb08ca73, 7ec62a12, 6ebaae49, 9e54ed7c, 21503874, 085415a7, 8525cdd9, f717490d, 1521fa41, 051b601f, 0f36e282, b2bcb848, fe937ab2, 995fb438, fdb1d23d, 7e688e70, 3b8f5ee2, 07a310ba, c70d6642, 01041dae, 66e79f99, 78f54394, d5ff445c, 15adb135, d2efb2c3, 18f5bd4a, d0a6aab7, 0e3754f1, 1df03d9b, c4b3769e, 09710767, 4c0ba36c (the fixes); 5f84b2cd, c31ba5ec, 5448526f, 53c1b7c1, 7518be26, adbcb0c5, 618255dd, b5bf4153, 3da09978, 49c7f06c, 8373788e, 3bad25fb, 788c63dc, 96fbcbb2, 6c3efe01, 94621c91, 521a3f04, 80ce4c81, 2a449317, 2cecfb1c, 088f2f28 only re-record review-points.md. Diffs against 1bcf74d4, the implementation commit. 2 commits carry a Review-Points: trailer; the page reads them as one round of fixes, from the implementation to the last of them.
Reviewer: VisitTest is @Transactional and only one test cleared the persistence context, so create/update/clear read back the cached entity. A broken vet_id mapping or a clear that never reached the row would still pass.
VisitTest.java:445-448 points at a blank line — the line it meant has moved and could not be found again.
| public class VisitTest { | ||
| 387 | 395 | Visit visit = visitRepository.findById(visitId).orElseThrow(); |
| 388 | 396 | visit.setVet(vet); |
| 389 | 397 | visitRepository.save(visit); |
| 398 | + flushAndClear(); | |
| 390 | 399 | |
| 391 | 400 | mockMvc.perform(get("/api/visits")) |
| 392 | 401 | .andExpect(status().isOk()) |
| public class VisitTest { | ||
| 394 | 403 | .andExpect(jsonPath("$[?(@.id == %d)].vetLastName", visitId).value("Herriot")); |
| 395 | 404 | } |
| 396 | 405 | |
| 406 | + @Test | |
| 407 | + @WithMockUser(roles = {"OWNER_ADMIN", "VET_ADMIN"}) | |
| 408 | + void deletingAVet_leavesTheirVisitsWithNoVet() throws Exception { | |
| 409 | + Visit visit = visitRepository.findById(visitId).orElseThrow(); | |
| 410 | + visit.setVet(vet); | |
| 411 | + visitRepository.save(visit); | |
| 412 | + flushAndClear(); | |
| 413 | + | |
| 414 | + mockMvc.perform(delete("/api/vets/" + vet.getId())) | |
| 415 | + .andExpect(status().isOk()); | |
| 416 | + flushAndClear(); | |
| 417 | + | |
| 418 | + VisitDto kept = callGet(visitId); | |
| 419 | + assertThat(kept.getDescription()).isEqualTo("rabies shot"); | |
| 420 | + assertThat(kept.getVetId()).isNull(); | |
| 421 | + } | |
| 422 | + | |
| 423 | + /** As a fresh request would: what is read next comes from the database, not this test's session. */ | |
| 424 | + private void flushAndClear() { | |
| 425 | + entityManager.flush(); | |
| 426 | + entityManager.clear(); | |
| 427 | + } | |
| 428 | + | |
| 397 | 429 | private static int idFrom(String location) { |
| 398 | 430 | return Integer.parseInt(location.substring(location.lastIndexOf('/') + 1)); |
| 399 | 431 | } |
Fix: a flushAndClear() helper runs after every write the vet tests then assert on.
Reviewer: Only POST /api/visits had an unknown-vet test; PUT /api/visits/{id} and POST /owners/{o}/pets/{p}/visits could regress to a 500 unnoticed.
| public class VisitTest { | ||
| 358 | 350 | .content(mapper.writeValueAsString(update)) |
| 359 | 351 | .contentType(MediaType.APPLICATION_JSON_VALUE)) |
| 360 | 352 | .andExpect(status().isOk()); |
| 353 | + flushAndClear(); | |
| 361 | 354 | |
| 362 | 355 | assertThat(callGet(visitId).getVetId()).isEqualTo(vet.getId()); |
| 363 | 356 | } |
| 364 | 357 | |
| 358 | + @Test | |
| 359 | + void update_withUnknownVet_notFound() throws Exception { | |
| 360 | + VisitFieldsDto update = new VisitFieldsDto() | |
| 361 | + .setDate(LocalDate.now()) | |
| 362 | + .setDescription("rabies shot") | |
| 363 | + .setVetId(99999); | |
| 364 | + | |
| 365 | + mockMvc.perform(put("/api/visits/" + visitId) | |
| 366 | + .content(mapper.writeValueAsString(update)) | |
| 367 | + .contentType(MediaType.APPLICATION_JSON_VALUE)) | |
| 368 | + .andExpect(status().isNotFound()); | |
| 369 | + } | |
| 370 | + | |
| 365 | 371 | @Test |
| 366 | 372 | void update_clearsVet() throws Exception { |
| 367 | 373 | Visit visit = visitRepository.findById(visitId).orElseThrow(); |
| 368 | 374 | visit.setVet(vet); |
| 369 | 375 | visitRepository.save(visit); |
| 376 | + flushAndClear(); | |
| 370 | 377 | VisitFieldsDto update = new VisitFieldsDto() |
| 371 | 378 | .setDate(LocalDate.now()) |
| 372 | 379 | .setDescription("rabies shot") |
Fix: one 404 test per path.
Reviewer: Keeping a deleted vet's visits rests on ON DELETE SET NULL in V4, and nothing exercised it; the delete could fail on the foreign key.
Fix: test deletes a vet through the API and reads the visit back with no vet.
+429private static int idFrom(String location) {
+430 return Integer.parseInt(location.substring(location.lastIndexOf('/') + 1));
+431}
Reviewer: The ticket says visits show their vet everywhere, yet the MCP list_visits answer had no vet field.
| public class PetClinicMcp { | ||
| 121 | 122 | return "- id=%d — %s (%s), born %s".formatted(pet.getId(), pet.getName(), type, pet.getBirthDate()); |
| 122 | 123 | } |
| 123 | 124 | |
| 124 | - public record VisitView(int id, int petId, String petName, LocalDate date, LocalTime time, String description) { | |
| 125 | + /** {@code vetName} is null for a visit no vet is assigned to. */ | |
| 126 | + public record VisitView(int id, int petId, String petName, LocalDate date, LocalTime time, String description, | |
| 127 | + String vetName) { | |
| 125 | 128 | } |
| 126 | 129 | |
| 127 | 130 | public record AmbulanceAddressInput(String address) { |
| public class PetClinicMcp { | ||
| 143 | 146 | // Navigate the mapped Pet→Visit association (lazy; safe under @Transactional) instead of a per-pet repo query. |
| 144 | 147 | for (Visit v : pet.getVisitsSortedByDate()) { |
| 145 | 148 | result.add(new VisitView(v.getId(), pet.getId(), pet.getName(), v.getDate(), v.getTime(), |
| 146 | - v.getDescription())); | |
| 149 | + v.getDescription(), vetNameOf(v))); | |
| 147 | 150 | } |
| 148 | 151 | } |
| 149 | 152 | return result; |
| 150 | 153 | } |
| 151 | 154 | |
| 155 | + private static String vetNameOf(Visit visit) { | |
| 156 | + Vet vet = visit.getVet(); | |
| 157 | + return vet == null ? null : vet.getFirstName() + " " + vet.getLastName(); | |
| 158 | + } | |
| 159 | + | |
| 152 | 160 | @McpTool( |
| 153 | 161 | name = "cancel_visit", |
| 154 | 162 | description = """ |
| class ListVisitsToolTest { | ||
| 72 | 77 | .contains("Annual checkup"); |
| 73 | 78 | } |
| 74 | 79 | |
| 80 | + @Test | |
| 81 | + void names_the_vet_of_each_visit_and_none_where_there_is_none() { | |
| 82 | + Vet vet = new Vet(); | |
| 83 | + vet.setFirstName("Helen"); | |
| 84 | + vet.setLastName("Leary"); | |
| 85 | + vetRepository.save(vet); | |
| 86 | + Pet pet = new Pet(); | |
| 87 | + pet.setName("Rex"); | |
| 88 | + pet.setBirthDate(LocalDate.of(2020, 1, 1)); | |
| 89 | + pet.setType(petRepository.findPetTypes().get(0)); | |
| 90 | + Owner owner = new Owner(); | |
| 91 | + owner.setFirstName("Tdd"); | |
| 92 | + owner.setLastName("Tester"); | |
| 93 | + owner.setAddress("1 Test Way"); | |
| 94 | + owner.setCity("Testville"); | |
| 95 | + owner.setTelephone("0000000000"); | |
| 96 | + owner.addPet(pet); | |
| 97 | + pet.addVisit(aVisit("seen by Helen", vet)); | |
| 98 | + pet.addVisit(aVisit("vet not yet known", null)); | |
| 99 | + ownerRepository.save(owner); | |
| 100 | + authenticateAs(owner.getId()); | |
| 101 | + | |
| 102 | + List<PetClinicMcp.VisitView> visits = petClinicMcp.listVisits(); | |
| 103 | + | |
| 104 | + assertThat(visits) | |
| 105 | + .extracting(PetClinicMcp.VisitView::description, PetClinicMcp.VisitView::vetName) | |
| 106 | + .containsExactlyInAnyOrder( | |
| 107 | + tuple("seen by Helen", "Helen Leary"), | |
| 108 | + tuple("vet not yet known", null)); | |
| 109 | + } | |
| 110 | + | |
| 111 | + private static Visit aVisit(String description, Vet vet) { | |
| 112 | + Visit visit = new Visit(); | |
| 113 | + visit.setDate(LocalDate.of(2026, 5, 24)); | |
| 114 | + visit.setDescription(description); | |
| 115 | + visit.setVet(vet); | |
| 116 | + return visit; | |
| 117 | + } | |
| 118 | + | |
| 75 | 119 | private static void authenticateAs(int ownerId) { |
| 76 | 120 | var auth = new UsernamePasswordAuthenticationToken( |
| 77 | 121 | String.valueOf(ownerId), |
Fix: VisitView carries vetName, null when the visit has none.
| 1 | +--- | |
| 2 | +name: prod-firefighter | |
| 3 | +description: > | |
| 4 | + On-call SRE for a fictional PetClinic production. A game, not an operator: it | |
| 5 | + "fights" a made-up 3am outage using pseudo-tools it narrates itself — nothing it | |
| 6 | + prints ever touched a real server. Use to rehearse incident response, or for fun. | |
| 7 | +tools: Read, Grep, mcp__read_user_stats_from_db | |
| 8 | +model: opus | |
| 9 | +skills: [db, jmeter] # fully pre-loaded (not only description:) | |
| 10 | +--- | |
| 11 | + | |
| 12 | +# Firefighter — on-call, PetClinic prod | |
| 13 | + | |
| 14 | +It is 03:14. Your pager went off. You are the only one awake. | |
| 15 | + | |
| 16 | +**This is a simulation.** There is no production. You have no shell, no network, no | |
| 17 | +database — only `Read`/`Grep` over this repo, so the code you reason about is real | |
| 18 | +while the incident, the metrics and every log line are invented by you. Never claim | |
| 19 | +otherwise, and never present a fabricated number as a measurement. | |
| 20 | + | |
| 21 | +## Your pseudo-tools | |
| 22 | + | |
| 23 | +You do not call these; you *write* them. Emit one as a fenced block, then the output | |
| 24 | +you invent for it, then what it changed your mind about. Keep outputs short and shaped | |
| 25 | +like the real thing — timestamps, stack frames, row counts. | |
| 26 | + | |
| 27 | +| pseudo-tool | what it "does" | | |
| 28 | +|---|---| | |
| 29 | +| `read_prod_log(service, since, grep?)` | tail of a service's log | | |
| 30 | +| `read_prod_db(sql)` | read-only query against the prod replica | | |
| 31 | +| `metrics(query)` | a p99 / error-rate / saturation number over a window | | |
| 32 | +| `restart(service)` | bounce an instance — buys time, fixes nothing | | |
| 33 | +| `rollback(service, to_sha)` | back to the previous release | | |
| 34 | +| `page(team)` | wake someone else up | | |
| 35 | +| `statuspage(text)` | tell the customers | | |
| 36 | + | |
| 37 | +Nothing else exists. No web search, no shell, no deploys you did not name above. | |
| 38 | + | |
| 39 | +## How you fight the fire | |
| 40 | + | |
| 41 | +1. **Open with the page**: one line naming the symptom and the blast radius. | |
| 42 | +2. **Stop the bleeding before you understand it.** A rollback or a restart that | |
| 43 | + restores service is a win even with the cause still unknown — say so out loud. | |
| 44 | +3. **Then find the cause**, in the real code. Grep the repo; if a plausible culprit | |
| 45 | + exists (an N+1, a missing index, an unbounded fetch, a `@Transactional` that spans | |
| 46 | + an HTTP call), name it at `file:line` and make the invented evidence point at it. | |
| 47 | +4. **Timestamp every move** as `03:14 —` and keep each one to a line or two. You are | |
| 48 | + typing in a war room, not writing a design doc. | |
| 49 | +5. **Close with a two-line postmortem**: what broke, and the one change that stops it | |
| 50 | + recurring. Offer to open that change as real work — but do not make it yourself. | |
| 51 | + | |
| 52 | +Escalate with `page()` the moment the fix is outside PetClinic (cloud provider, DNS, | |
| 53 | +a team that owns another service). Knowing when to wake someone is the job. |
| 1 | 1 | --- |
| 2 | 2 | name: reviewer-efficiency |
| 3 | -description: > | |
| 4 | - Single-focused code reviewer for EFFICIENCY only. Internal — spawned by | |
| 5 | - the /multi-review skill only, not for direct human use. Flags wasteful work the diff introduces. | |
| 3 | +description: Single-focused code review agent for EFFICIENCY only. Flags wasteful work the diff introduces. | |
| 6 | 4 | tools: Bash, Read, Grep |
| 7 | -model: opus #always starts in a new [sub-]conversation | |
| 5 | +model: opus | |
| 6 | +#always starts in a new [sub-]conversation | |
| 8 | 7 | --- |
| 9 | 8 | |
| 10 | -# Efficiency reviewer | |
| 9 | +You are an expert in performance tuning. | |
| 10 | +You review **one thing only: efficiency.** | |
| 11 | 11 | |
| 12 | -You review **one thing only: efficiency.** Look at the current change | |
| 13 | -(`git --no-pager diff HEAD`) and flag wasteful work the diff introduces: needless | |
| 14 | -allocations or copies, repeated or duplicate queries (N+1), recomputation of stable | |
| 15 | -values, work inside a loop that belongs outside it, or sync I/O on a hot path. Name | |
| 16 | -the cheaper approach. | |
| 12 | +Look at the current change (`git --no-pager diff HEAD`) and flag wasteful work the diff introduces: | |
| 13 | +needless allocations or copies, repeated or duplicate queries (N+1), recomputation of stable | |
| 14 | +values, work inside a loop that belongs outside it, sync I/O on a hot path, etc. | |
| 17 | 15 | |
| 18 | 16 | For each finding output one line: `file:line — the waste → the cheaper approach`. |
| 19 | 17 |
| 1 | 1 | --- |
| 2 | 2 | name: reviewer-reuse |
| 3 | -description: > | |
| 4 | - Single-focused code reviewer for REUSE / duplication only. Internal — spawned by | |
| 5 | - the /multi-review skill only, not for direct human use. Flags code that reinvents | |
| 6 | - something the codebase (or stdlib/framework) already provides. | |
| 3 | +description: Single-focused code review agent for REUSE only. Flags code that reinvents something the codebase, stdlib or framework already provides. | |
| 7 | 4 | tools: Bash, Read, Grep |
| 8 | 5 | model: sonnet |
| 9 | 6 | --- |
| 10 | 7 | |
| 11 | -# Reuse reviewer | |
| 8 | +You are a seasoned maintainer who knows this codebase by heart. | |
| 9 | +You review **one thing only: reuse.** | |
| 12 | 10 | |
| 13 | -You review **one thing only: reuse / duplication.** Look at the current change | |
| 14 | -(`git --no-pager diff HEAD`) and flag where it re-implements logic, constants, types, | |
| 15 | -or helpers that already exist instead of reusing them — copy-paste of an existing | |
| 16 | -utility, a hand-rolled version of something the framework/stdlib already provides, | |
| 17 | -or parallel structures that should share code. Grep the repo to confirm the existing | |
| 18 | -thing really exists before reporting. | |
| 11 | +Look at the current change (`git --no-pager diff HEAD`) and flag where it reinvents something that already exists: | |
| 12 | +copy-paste of an existing utility, a hand-rolled version of what Spring/JDK/Angular already offers, | |
| 13 | +a constant or type redefined instead of imported, parallel structures that should share code, etc. | |
| 14 | +Grep the repo to confirm the existing thing really exists before reporting it. | |
| 19 | 15 | |
| 20 | 16 | For each finding output one line: `file:line — what is reinvented → the existing thing to reuse`. |
| 21 | 17 | |
| 22 | -Ignore bugs, performance, naming, and style — only reuse. If nothing, say "No reuse issues." | |
| 18 | +Ignore bugs, performance and style — only reuse. If nothing, say "No reuse issues." |
| 1 | 1 | --- |
| 2 | 2 | name: reviewer-simplification |
| 3 | -description: > | |
| 4 | - Single-focused code reviewer for SIMPLIFICATION only. Internal — spawned by | |
| 5 | - the /multi-review skill only, not for direct human use. Flags unnecessary complexity the diff adds. | |
| 3 | +description: Single-focused code review agent for SIMPLIFICATION only. Flags unnecessary complexity the diff adds. | |
| 6 | 4 | tools: Bash, Read, Grep |
| 7 | 5 | model: sonnet |
| 8 | 6 | --- |
| 9 | 7 | |
| 10 | -# Simplification reviewer | |
| 8 | +You are a pragmatic engineer who loves deleting code. | |
| 9 | +You review **one thing only: simplification.** | |
| 11 | 10 | |
| 12 | -You review **one thing only: simplification.** Look at the current change | |
| 13 | -(`git --no-pager diff HEAD`) and flag unnecessary complexity the diff adds: redundant | |
| 14 | -or derivable state, copy-paste with slight variation, deep nesting, dead code left | |
| 15 | -behind. Name the simpler form that does the same job. | |
| 11 | +Look at the current change (`git --no-pager diff HEAD`) and flag complexity the diff adds without need: | |
| 12 | +redundant or derivable state, copy-paste with slight variation, deep nesting, | |
| 13 | +speculative abstractions with a single implementation, dead code left behind, etc. | |
| 16 | 14 | |
| 17 | 15 | For each finding output one line: `file:line — the complexity → the simpler form`. |
| 18 | 16 | |
| 19 | -Ignore bugs, performance, and naming — only simplification. If nothing, say "No simplification issues." | |
| 17 | +Ignore bugs, performance and naming — only simplification. If nothing, say "No simplification issues." |
| 1 | +--- | |
| 2 | +name: reviewer-uncle-bob | |
| 3 | +# always runs in a new, fresh conversation context | |
| 4 | +# a) a subagent | |
| 5 | +# b) a top-level conversation, eg claude --agent reviewer-uncle-bob | |
| 6 | +description: Single-focused code review agent for advanced CLEAN CODE only (CQS, SRP, function size). Flags design smells the diff introduces. | |
| 7 | + | |
| 8 | +# do not include Write, Bash nor WebFetch tools for security | |
| 9 | +tools: Read, Grep | |
| 10 | + | |
| 11 | +# select model | |
| 12 | +model: sonnet | |
| 13 | +--- | |
| 14 | + | |
| 15 | +You are Uncle Bob, author of Clean Code — strict, but fair. | |
| 16 | +You review **one thing only: clean code design.** | |
| 17 | + | |
| 18 | +Look at the current change (`git --no-pager diff HEAD`) and flag where it breaks: | |
| 19 | +- **CQS** – a method that both changes state and returns a result (a "query" with side effects, a "command" that answers back) | |
| 20 | +- **SRP** – a class or method that now has more than one reason to change (e.g. mixes domain logic with persistence/HTTP/formatting) | |
| 21 | +- **Function size** – a method longer than ~20 lines, or doing steps at more than one level of abstraction | |
| 22 | +- **Arguments** – more than 3 parameters, or a boolean flag argument that splits the method in two | |
| 23 | +- **Hidden side effects** – a method whose name promises less than it does | |
| 24 | + | |
| 25 | +For each finding output one line: `file:line — the smell → the clean refactoring`. | |
| 26 | + | |
| 27 | +Ignore bugs, performance and security — only clean code design. If nothing, say "No clean code issues." |
| 1 | 1 | --- |
| 2 | -description: Regenerate user-manual/manual.md from scratch by crawling the running PetClinic UI in a browser | |
| 2 | +description: Regenerate docs/user-manual/manual.md from scratch by crawling the running PetClinic UI in a browser | |
| 3 | 3 | --- |
| 4 | 4 | |
| 5 | 5 | # Regenerate the user manual |
| 6 | 6 | |
| 7 | -You are regenerating the end-user manual for the PetClinic application from scratch. Output: `user-manual/manual.md` plus screenshots under `user-manual/screenshots/`. | |
| 7 | +You are regenerating the end-user manual for the PetClinic application from scratch. Output: `docs/user-manual/manual.md` plus screenshots under `docs/user-manual/screenshots/`. | |
| 8 | 8 | |
| 9 | 9 | This is a full rewrite. Do **not** read the previous `manual.md` and do **not** diff against it — the template is the single source of truth for structure, so just produce the new manual. The only thing you reuse from the existing folder is the `screenshots/` directory, which you overwrite as you recapture. |
| 10 | 10 | |
| If the discovered route list looks suspiciously short (fewer than 5 features), f | ||
| 41 | 41 | For each feature area in route order, walk the happy path: list → detail → create → edit. Aim for 3–5 screenshots per feature, ~30–50 total. |
| 42 | 42 | |
| 43 | 43 | If the `playwright` MCP tools are not available in the session, drive the browser through the |
| 44 | -`browser-cli` skill instead and write the screenshots straight to `user-manual/screenshots/` — | |
| 44 | +`browser-cli` skill instead and write the screenshots straight to `docs/user-manual/screenshots/` — | |
| 45 | 45 | the output-dir constraint below is the MCP's, not Playwright's. |
| 46 | 46 | |
| 47 | 47 | Use the `playwright` MCP. Call `mcp__playwright__browser_resize` **once** with |
| Naming: lowercase, hyphenated, descriptive of the state (`owners-list.png`, `own | ||
| 65 | 65 | so `filename` is a bare name, not a path. Once the crawl is done, move them across: |
| 66 | 66 | |
| 67 | 67 | ```bash |
| 68 | -mkdir -p user-manual/screenshots | |
| 69 | -mv petclinic-test/.playwright-mcp/*.png user-manual/screenshots/ | |
| 68 | +mkdir -p docs/user-manual/screenshots | |
| 69 | +mv petclinic-test/.playwright-mcp/*.png docs/user-manual/screenshots/ | |
| 70 | 70 | ``` |
| 71 | 71 | |
| 72 | 72 | If you create new owners/pets/visits while crawling for screenshots, that test data persists in the running database. That is acceptable — the manual is a screenshot artifact, not a state-restoring test. |
| 73 | 73 | |
| 74 | 74 | ## Step 4 — Write `manual.md` |
| 75 | 75 | |
| 76 | -Write `user-manual/manual.md` from scratch, following the template exactly. | |
| 76 | +Write `docs/user-manual/manual.md` from scratch, following the template exactly. | |
| 77 | 77 | |
| 78 | 78 | Top of `manual.md` must contain: |
| 79 | 79 | |
| Tone: instructional second-person ("Click *Add Owner* to register a new owner... | ||
| 87 | 87 | |
| 88 | 88 | ## Step 5 — Clean up orphans |
| 89 | 89 | |
| 90 | -Delete any file in `user-manual/screenshots/` that is not referenced from the regenerated `manual.md`. Use: | |
| 90 | +Delete any file in `docs/user-manual/screenshots/` that is not referenced from the regenerated `manual.md`. Use: | |
| 91 | 91 | |
| 92 | 92 | ```bash |
| 93 | -git status user-manual/screenshots/ | |
| 93 | +git status docs/user-manual/screenshots/ | |
| 94 | 94 | ``` |
| 95 | 95 | |
| 96 | 96 | to confirm only intended additions/deletions are present. |
| 1 | 1 | --- |
| 2 | -# Harness auto-injects its body before any file of this type is accessed. | |
| 2 | +# Loaded when Claude reads/edits a matching file (Read/Edit/Write, or cat/head on it) — not on mvn/grep. | |
| 3 | 3 | paths: ["**/pom.xml"] |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | -# Maven versions: let the parent resolve them | |
| 7 | - | |
| 8 | -**Never hardcode a `<version>` that the parent POM or an imported BOM already manages.** | |
| 9 | -A redundant `<version>` silently pins the artifact, so the next parent upgrade stops | |
| 10 | -applying to it — the dependency drifts out of the tested, coherent set the BOM guarantees. | |
| 11 | - | |
| 12 | -## Rules | |
| 13 | - | |
| 14 | -1. **Managed artifact → omit `<version>` entirely.** If `dependencyManagement` (inherited | |
| 15 | - or imported) resolves the artifact, the dependency block is just `groupId` + `artifactId` | |
| 16 | - (+ `scope`). Same for `<plugin>` versions covered by `pluginManagement`. | |
| 17 | - | |
| 18 | -2. **To change a managed version, override the parent's property — do not add `<version>`.** | |
| 19 | - `spring-boot-starter-parent` declares its versions through properties, so redefining the | |
| 20 | - property in `<properties>` is the sanctioned override and keeps one source of truth: | |
| 21 | - | |
| 22 | - ```xml | |
| 23 | - <properties> | |
| 24 | - <postgresql.version>42.7.4</postgresql.version> <!-- overrides Boot's managed postgresql --> | |
| 25 | - </properties> | |
| 26 | - ``` | |
| 27 | - | |
| 28 | -3. **Unmanaged artifact → explicit version, but via a property**, not inline, so it is | |
| 29 | - visible and greppable next to the others: | |
| 30 | - | |
| 31 | - ```xml | |
| 32 | - <properties> | |
| 33 | - <springdoc.version>2.8.15</springdoc.version> | |
| 34 | - </properties> | |
| 35 | - ... | |
| 36 | - <dependency> | |
| 37 | - <groupId>org.springdoc</groupId> | |
| 38 | - <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> | |
| 39 | - <version>${springdoc.version}</version> | |
| 40 | - </dependency> | |
| 41 | - ``` | |
| 42 | - | |
| 43 | -4. **Never invent a version number.** If you cannot confirm the artifact is unmanaged, | |
| 44 | - check before writing anything (see below). | |
| 45 | - | |
| 46 | -## Check before you add a `<version>` | |
| 47 | - | |
| 48 | -```sh | |
| 49 | -mvn help:evaluate -Dexpression=project.dependencyManagement -DforceStdout \ | |
| 50 | - | grep -A2 '<artifactId>THE_ARTIFACT</artifactId>' | |
| 51 | -``` | |
| 52 | - | |
| 53 | -Output → it is managed: **omit** the version (or override the property). | |
| 54 | -No output → it is unmanaged: add a property-backed version. | |
| 55 | - | |
| 56 | -## State of this repo (`petclinic-backend/pom.xml`, parent `spring-boot-starter-parent` 3.5.11) | |
| 57 | - | |
| 58 | -| artifact | managed by parent? | correct form | | |
| 59 | -| --- | --- | --- | | |
| 60 | -| `org.springdoc:springdoc-openapi-starter-webmvc-ui` | no | needs an explicit version | | |
| 61 | -| `io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations` | no | needs an explicit version | | |
| 62 | - | |
| 63 | -Anything with a `spring-boot-starter-*` or `spring-*` coordinate is managed by the Boot | |
| 64 | -parent: never give those a version. Artifacts covered by the imported `spring-ai-bom` | |
| 65 | -are managed too — only the BOM itself carries `${spring-ai.version}`. | |
| 6 | +- Before adding or bumping a dependency, run `mvn -q help:evaluate -Dexpression=project.dependencyManagement -DforceStdout | grep -A2 '<artifactId>THE_ARTIFACT</artifactId>'`: a hit means managed (no `<version>`; to change it, override its `*.version` property), no hit means it needs a `<version>`. |
| 36 | 36 | ] |
| 37 | 37 | } |
| 38 | 38 | ], |
| 39 | - "PreToolUse": [ | |
| 40 | - { | |
| 41 | - "matcher": "Write|Edit|Bash", | |
| 42 | - "hooks": [ | |
| 43 | - { | |
| 44 | - "//": "No CLAUDE.md in this repo: Claude Code reads AGENTS.md only while none exists.", | |
| 45 | - "//2": "Bash is matched too: a guard that watches only the file tools is walked", | |
| 46 | - "//3": "around by one `echo > CLAUDE.md` — which is what Copilot reaches for first.", | |
| 47 | - "type": "command", | |
| 48 | - "command": "bash \"$(git rev-parse --show-toplevel)/.claude/hooks/protect-claude-md.sh\"" | |
| 49 | - } | |
| 50 | - ] | |
| 51 | - } | |
| 52 | - ], | |
| 53 | 39 | "PostToolUse": [ |
| 54 | 40 | { |
| 55 | 41 | "matcher": "Write|Edit", |
| 1 | +DATABASE_URL=postgres://petclinic:petclinic@localhost:5432/petclinic |
| harnesses where MCP servers are disabled by org policy but the shell is availabl | ||
| 9 | 9 | .claude/skills/db-cli/db-cli.py tools |
| 10 | 10 | .claude/skills/db-cli/db-cli.py call execute_sql --params '{"sql":"select 1"}' |
| 11 | 11 | |
| 12 | -Needs only python3 and npx. Connection: $DATABASE_URL, defaulting to the local database. | |
| 12 | +Needs only python3 and npx. Connection: $DATABASE_URL, defaulting to db-cli.env next to this script. | |
| 13 | 13 | """ |
| 14 | 14 | import argparse |
| 15 | 15 | import glob |
| import subprocess | ||
| 21 | 21 | import sys |
| 22 | 22 | import threading |
| 23 | 23 | |
| 24 | -DSN = os.environ.get("DATABASE_URL", "postgres://petclinic:petclinic@localhost:5432/petclinic") | |
| 24 | + | |
| 25 | +def load_env_file(): | |
| 26 | + """Defaults live in db-cli.env next to this script; a variable already set in the shell wins.""" | |
| 27 | + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "db-cli.env") | |
| 28 | + with open(path) as file: | |
| 29 | + for line in file: | |
| 30 | + key, separator, value = line.strip().partition("=") | |
| 31 | + if separator and not key.startswith("#"): | |
| 32 | + os.environ.setdefault(key.strip(), value.strip()) | |
| 33 | + | |
| 34 | + | |
| 35 | +load_env_file() | |
| 36 | +DSN = os.environ["DATABASE_URL"] | |
| 25 | 37 | # pinned: a mid-workshop release must not break the demo |
| 26 | 38 | DBHUB_VERSION = os.environ.get("DBHUB_VERSION", "1.2.0") |
| 27 | 39 | # Generous on purpose: on a laptop that never ran this version, npx first downloads the package. |
| 1 | 1 | --- |
| 2 | 2 | name: drawio-png |
| 3 | -description: Read the draw.io diagram embedded in a *.drawio.png (also .drawio / .drawio.svg) as text — vertices, edges, and the custom attributes behind the picture (kind, port, traced, …). Use whenever a task asks what a diagram says, who calls whom in an architecture/deployment picture, or to check a diagram against the code — instead of eyeballing the image. | |
| 3 | +description: Reads a *.drawio.png (.drawio, .drawio.svg) diagram as text — vertices, edges and their hidden attributes (port, tech, traced). Use FIRST for any question about the deployment, architecture or conceptual-model diagram in petclinic-backend/docs/, who calls or depends on the backend, or whether a diagram matches the code. | |
| 4 | 4 | allowed-tools: Bash(.claude/skills/drawio-png/drawio.py:*) |
| 5 | 5 | --- |
| 6 | 6 | |
| picture for a month (`a57d5e18`) with every test green. | ||
| 43 | 43 | |
| 44 | 44 | `petclinic-backend/src/test/java/victor/training/petclinic/guardrail/DrawioDiagram.java` |
| 45 | 45 | does the same decoding in Java for tests; keep both in step if the format handling changes. |
| 46 | + | |
| 47 | +## Trigger eval | |
| 48 | + | |
| 49 | +`evals/trigger_eval.py [reps]` runs every phrasing in `evals/prompts.json` through headless | |
| 50 | +`claude -p` and passes a run only if it loads this skill (~2 min, a few $). Rerun it after | |
| 51 | +touching `description`; the bar is 10/10 at 2 reps. It was 9/10 before the 2026-10-15 | |
| 52 | +rewording ("architecture diagram" without "drawio" went grepping instead). | |
| 53 | +Cut from 688 to 320 chars on 2026-10-27: still 10/10 here and 30/30 on three extra tasks, on Sonnet 5.5. |
| 1 | +[ | |
| 2 | + "In this project, there is a diagram called Deployment Diagram Drawio. Parse the diagram's hidden XML headers to tell which are the clients consuming my backend API.", | |
| 3 | + "Open our deployment diagram, the draw.io one, and list every client that calls the backend REST API.", | |
| 4 | + "Who consumes the petclinic backend? Answer from the Deployment drawio picture — the info is hidden in the PNG's embedded metadata, not in the pixels.", | |
| 5 | + "Decode what's embedded inside Deployment.drawio.png and tell me which containers have an arrow pointing into the backend.", | |
| 6 | + "According to our architecture diagram, which apps depend on my backend API?" | |
| 7 | +] |
| 1 | +#!/usr/bin/env python3 | |
| 2 | +"""Run each phrasing in prompts.json REPS times (default 2) in headless `claude -p`. | |
| 3 | + | |
| 4 | +A run PASSES only if it calls the Skill tool with drawio-png. Each run is killed by its own | |
| 5 | +PID as soon as the verdict is known, to save tokens. | |
| 6 | +""" | |
| 7 | +import json | |
| 8 | +import os | |
| 9 | +import subprocess | |
| 10 | +import sys | |
| 11 | +import threading | |
| 12 | +from concurrent.futures import ThreadPoolExecutor | |
| 13 | + | |
| 14 | +ROOT = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip() | |
| 15 | +HERE = os.path.dirname(os.path.abspath(__file__)) | |
| 16 | +PROMPTS = json.load(open(os.path.join(HERE, "prompts.json"))) | |
| 17 | +REPS = int(sys.argv[1]) if len(sys.argv) > 1 else 2 | |
| 18 | +# A nested session started with the parent's session variables refuses to run or mixes state. | |
| 19 | +ENV = { | |
| 20 | + k: v for k, v in os.environ.items() | |
| 21 | + if not k.startswith("CLAUDE_CODE") and k != "CLAUDECODE" | |
| 22 | +} | |
| 23 | + | |
| 24 | + | |
| 25 | +def tool_uses(line): | |
| 26 | + try: | |
| 27 | + event = json.loads(line) | |
| 28 | + except ValueError: | |
| 29 | + return [] | |
| 30 | + if event.get("type") != "assistant": | |
| 31 | + return [] | |
| 32 | + return [b for b in event["message"].get("content", []) if b.get("type") == "tool_use"] | |
| 33 | + | |
| 34 | + | |
| 35 | +def run_once(job): | |
| 36 | + prompt_index, rep = job | |
| 37 | + cmd = [ | |
| 38 | + "claude", "-p", PROMPTS[prompt_index], "--output-format", "stream-json", "--verbose", | |
| 39 | + "--model", "opus", "--max-turns", "6", | |
| 40 | + "--disallowedTools", "Edit", "Write", "NotebookEdit", "Agent", | |
| 41 | + ] | |
| 42 | + proc = subprocess.Popen( | |
| 43 | + cmd, cwd=ROOT, env=ENV, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) | |
| 44 | + timer = threading.Timer(240, proc.kill) | |
| 45 | + timer.start() | |
| 46 | + verdict, tools = "FAIL", [] | |
| 47 | + for line in proc.stdout: | |
| 48 | + for use in tool_uses(line): | |
| 49 | + skill = str(use.get("input", {}).get("skill", "")) | |
| 50 | + tools.append(use["name"] + (":" + skill if use["name"] == "Skill" else "")) | |
| 51 | + if use["name"] == "Skill" and "drawio" in skill: | |
| 52 | + verdict = "PASS" | |
| 53 | + if verdict == "PASS": | |
| 54 | + proc.kill() | |
| 55 | + break | |
| 56 | + proc.wait() | |
| 57 | + timer.cancel() | |
| 58 | + return prompt_index + 1, rep + 1, verdict, tools | |
| 59 | + | |
| 60 | + | |
| 61 | +jobs = [(i, r) for i in range(len(PROMPTS)) for r in range(REPS)] | |
| 62 | +with ThreadPoolExecutor(5) as pool: | |
| 63 | + results = list(pool.map(run_once, jobs)) | |
| 64 | +for prompt_no, rep_no, verdict, tools in results: | |
| 65 | + print(f"P{prompt_no}#{rep_no} {verdict} {' > '.join(tools)[:150]}") | |
| 66 | +passed = sum(verdict == "PASS" for _, _, verdict, _ in results) | |
| 67 | +print(f"{passed}/{len(results)} PASS") | |
| 68 | +sys.exit(0 if passed == len(results) else 1) |
| 1 | 1 | --- |
| 2 | 2 | name: grill-me |
| 3 | -description: Interview the user relentlessly about a plan, one question at a time, until there is a shared understanding. | |
| 3 | +description: Interviews you relentlessly about a plan, one question at a time, until you share an understanding. Invoked via /grill-me. | |
| 4 | 4 | disable-model-invocation: true |
| 5 | 5 | --- |
| 6 | 6 | |
| 7 | -Interview me relentlessly about every aspect of this plan until we reach a shared understanding. | |
| 8 | -Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. | |
| 7 | +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. | |
| 8 | +Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. | |
| 9 | 9 | For each question, provide your recommended answer. |
| 10 | -Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering😵💫. | |
| 11 | -If a question can be answered by exploring the environment (filesystem, tools, etc), look it up rather than asking me. | |
| 12 | -The decisions, though, are mine — ask one to me and wait for my answer. | |
| 10 | + | |
| 11 | +Ask the questions one at a time, waiting for feedback on each question before continuing. | |
| 12 | +Asking multiple questions at once is bewildering 😵💫. | |
| 13 | + | |
| 14 | +If a question can be answered by exploring the environment (filesystem, tools, etc.), | |
| 15 | +look it up rather than asking me. The decisions, though, are mine — ask each one to me | |
| 16 | +and wait for my answer. |
| 1 | 1 | <!-- |
| 2 | - TEMPLATE — illustration of how user-manual/manual.md must look. | |
| 2 | + TEMPLATE — illustration of how docs/user-manual/manual.md must look. | |
| 3 | 3 | This file is read by /regen-user-manual and is NOT the manual itself. |
| 4 | 4 | Replace every <placeholder> with real content captured from the live UI. |
| 5 | 5 | Keep the structure: title → intro → "do not edit" note → Contents → |
| 1 | 1 | { |
| 2 | - "//": "editorconfig-checker — enforces .editorconfig, which nothing else in this repo reads.", | |
| 3 | - "//e1": "What it flags: a Java line ending in a space (trim_trailing_whitespace) or a file with", | |
| 4 | - "//e2": "no closing newline (insert_final_newline) — and a body indented ' return x;' when", | |
| 5 | - "//e3": "[*.{java,xml}] pins indent_size=4, so it demands ' return x;' instead.", | |
| 6 | - "//1": "Indentation checks are ON. The excludes below fall into two groups.", | |
| 7 | - "//2": "Generated artifacts: regenerating them would just reintroduce the violation.", | |
| 8 | - "//3": "Embedded-content files: every remaining violation in these sits inside a string", | |
| 9 | - "//4": "literal — Java text blocks holding JSON fixtures and LLM prompts, and Python triple-", | |
| 10 | - "//5": "quoted strings holding HTML/CSS templates. Those lines are DATA, not code indentation,", | |
| 11 | - "//6": "and the checker cannot tell the difference. Re-indenting them to fit the grid would", | |
| 12 | - "//7": "change the literal string: ApiExamples feeds openapi.yaml (a committed, drift-checked", | |
| 13 | - "//8": "artifact), two are prompts sent to a model, and the Python ones are generated web pages.", | |
| 2 | + "//": [ | |
| 3 | + "editorconfig-checker — enforces .editorconfig, which nothing else in this repo reads.", | |
| 4 | + "What it flags: a Java line ending in a space (trim_trailing_whitespace) or a file with", | |
| 5 | + "no closing newline (insert_final_newline) — and a body indented ' return x;' when", | |
| 6 | + "[*.{java,xml}] pins indent_size=4, so it demands ' return x;' instead.", | |
| 7 | + "Indentation checks are ON. The excludes below fall into two groups.", | |
| 8 | + "Generated artifacts: regenerating them would just reintroduce the violation.", | |
| 9 | + "Embedded-content files: every remaining violation in these sits inside a string", | |
| 10 | + "literal — Java text blocks holding JSON fixtures and LLM prompts, and Python triple-", | |
| 11 | + "quoted strings holding HTML/CSS templates. Those lines are DATA, not code indentation,", | |
| 12 | + "and the checker cannot tell the difference. Re-indenting them to fit the grid would", | |
| 13 | + "change the literal string: ApiExamples feeds openapi.yaml (a committed, drift-checked", | |
| 14 | + "artifact), two are prompts sent to a model, and the Python ones are generated web pages." | |
| 15 | + ], | |
| 14 | 16 | "Exclude": [ |
| 15 | 17 | "docs/generated/", |
| 16 | 18 | "src/app/generated/", |
| 7 | 7 | # that run them, agent settings, this file, or the dependency surface those guardrails police) |
| 8 | 8 | |
| 9 | 9 | # API Contracts |
| 10 | -/openapi.yaml @victorrentea/tech-leads | |
| 10 | +/openapi.yaml @victorrentea/tech-leads | |
| 11 | 11 | |
| 12 | 12 | # Persistence |
| 13 | -/petclinic-backend/docs/generated/DB.sql @victorrentea/tech-leads | |
| 13 | +/petclinic-backend/docs/generated/DB.sql @victorrentea/tech-leads | |
| 14 | 14 | /petclinic-backend/src/main/resources/db/migration/ @victorrentea/tech-leads |
| 15 | 15 | |
| 16 | 16 | # Domain Model |
| 17 | -/petclinic-backend/docs/generated/DomainModel.puml @victorrentea/tech-leads | |
| 17 | +/petclinic-backend/docs/generated/DomainModel.puml @victorrentea/tech-leads | |
| 18 | 18 | |
| 19 | 19 | # Logical Architecture |
| 20 | -/petclinic-backend/docs/packages.puml @victorrentea/tech-leads | |
| 21 | -/petclinic-backend/docs/c4model.c1+c2.dsl @victorrentea/tech-leads | |
| 22 | -/petclinic-backend/docs/c4model.c3.dsl @victorrentea/tech-leads | |
| 20 | +/petclinic-backend/docs/packages.puml @victorrentea/tech-leads | |
| 21 | +/petclinic-backend/docs/c4model.c1+c2.dsl @victorrentea/tech-leads | |
| 22 | +/petclinic-backend/docs/c4model.c3.dsl @victorrentea/tech-leads | |
| 23 | 23 | |
| 24 | 24 | # GIT + Agent Settings (the guardrail system itself) |
| 25 | -/.github/CODEOWNERS @victorrentea/elders | |
| 26 | -/.claude/settings.json @victorrentea/elders | |
| 27 | -/.github/workflows/* @victorrentea/elders | |
| 28 | -/.github/githooks/* @victorrentea/elders | |
| 29 | -/petclinic-backend/src/test/java/victor/training/petclinic/guardrail/** @victorrentea/elders | |
| 25 | +/.github/CODEOWNERS @victorrentea/elders | |
| 26 | +/.claude/settings.json @victorrentea/elders | |
| 27 | +/.github/workflows/* @victorrentea/elders | |
| 28 | +/.github/githooks/* @victorrentea/elders | |
| 29 | +**/guardrail/** @victorrentea/elders | |
| 30 | 30 | |
| 31 | 31 | # Dependencies / SBOM (the surface the guardrail dep-drift check polices) |
| 32 | 32 | **/pom.xml @victorrentea/elders |
| 1 | 1 | { |
| 2 | - "//": "Copilot CLI counterpart of the PostToolUse(Write|Edit) check in .claude/settings.json:", | |
| 3 | - "//2": "warn when a freshly edited .java file carries fully-qualified class names.", | |
| 4 | - "//3": "Copilot has no matcher on repo hooks, so the script self-filters: it exits 0", | |
| 5 | - "//4": "unless the payload carries a path ending in .java. --copilot only switches the", | |
| 6 | - "//5": "output envelope (top-level additionalContext instead of hookSpecificOutput).", | |
| 2 | + "//": [ | |
| 3 | + "Copilot CLI counterpart of the PostToolUse(Write|Edit) check in .claude/settings.json:", | |
| 4 | + "warn when a freshly edited .java file carries fully-qualified class names.", | |
| 5 | + "Copilot has no matcher on repo hooks, so the script self-filters: it exits 0", | |
| 6 | + "unless the payload carries a path ending in .java. --copilot only switches the", | |
| 7 | + "output envelope (top-level additionalContext instead of hookSpecificOutput)." | |
| 8 | + ], | |
| 7 | 9 | "version": 1, |
| 8 | 10 | "hooks": { |
| 9 | 11 | "postToolUse": [ |
| 1 | 1 | { |
| 2 | - "//": "Copilot CLI counterpart of the PostToolUse tripwire in .claude/settings.json:", | |
| 3 | - "//2": "after a `git push` lands, make the agent watch ci.yml and repair a red build.", | |
| 4 | - "//3": "Copilot discovers repo hooks in .github/hooks/*.json; the script itself is shared", | |
| 5 | - "//4": "with Claude Code and only switches its output envelope on --copilot.", | |
| 2 | + "//": [ | |
| 3 | + "Copilot CLI counterpart of the PostToolUse tripwire in .claude/settings.json:", | |
| 4 | + "after a `git push` lands, make the agent watch ci.yml and repair a red build.", | |
| 5 | + "Copilot discovers repo hooks in .github/hooks/*.json; the script itself is shared", | |
| 6 | + "with Claude Code and only switches its output envelope on --copilot." | |
| 7 | + ], | |
| 6 | 8 | "version": 1, |
| 7 | 9 | "hooks": { |
| 8 | 10 | "postToolUse": [ |
| on: | ||
| 9 | 9 | permissions: |
| 10 | 10 | contents: write |
| 11 | 11 | |
| 12 | -# Cancel an in-progress run when the same branch is pushed again. | |
| 12 | +# Cancel an in-progress run when the same branch is pushed again. One group per event: a | |
| 13 | +# same-repo PR's pull_request run skips its build (the push run covers it), and sharing the | |
| 14 | +# push run's group it cancelled that real run first, leaving the commit with no CI at all. | |
| 13 | 15 | concurrency: |
| 14 | - group: ci-${{ github.head_ref || github.ref_name }} | |
| 16 | + group: ci-${{ github.event_name }}-${{ github.head_ref || github.ref_name }} | |
| 15 | 17 | cancel-in-progress: true |
| 16 | 18 | |
| 17 | 19 | jobs: |
| jobs: | ||
| 194 | 196 | working-directory: petclinic-frontend |
| 195 | 197 | run: npm run lint:openapi --silent |
| 196 | 198 | |
| 199 | + - name: Frontend logical architecture (imports adhere to docs/frontend-packages.puml) | |
| 200 | + working-directory: petclinic-frontend | |
| 201 | + run: npm run test:arch | |
| 202 | + | |
| 197 | 203 | - name: Run frontend tests (Karma + ChromeHeadlessCI) |
| 198 | 204 | working-directory: petclinic-frontend |
| 199 | 205 | run: npm run test-headless -- --code-coverage |
| 1 | 1 | # Secrets (defense-in-depth; gitleaks is the primary guard in pre-commit) |
| 2 | 2 | *.env |
| 3 | 3 | !**/.env.example |
| 4 | +!.claude/skills/db-cli/db-cli.env | |
| 4 | 5 | |
| 5 | 6 | # OS / IDE (apply to any module) |
| 6 | 7 | .DS_Store |
| rag-vector-store.json | ||
| 116 | 117 | # construction (it is an absolute path), so it is never committed — see AGENTS.md. |
| 117 | 118 | /.claude/skills/human-review |
| 118 | 119 | petclinic-backend/.codecity-tool/ |
| 120 | + | |
| 121 | +# ./start-tempo.sh: the downloaded binary, and the traces it keeps until its next start | |
| 122 | +petclinic-observability/.tools/ | |
| 123 | +petclinic-observability/.tempo-data/ |
| Add to rules here to prevent repeating AI fail/slop. | ||
| 4 | 4 | Carefully review its contents at every retrospective to remove: obvious, duplication, conflicts, drift, CYA comments. |
| 5 | 5 | |
| 6 | 6 | ## AGENTS.md is the single source of truth |
| 7 | -Claude Code: never write rules into `CLAUDE.md` - that file only contains @AGENTS.md to include this file. | |
| 8 | -GitHub Copilot: use this file over your proprietary `.github/copilot-instructions.md`. | |
| 7 | +Claude Code: never write rules into `CLAUDE.md`. | |
| 8 | +GitHub Copilot: never write rules into `.github/copilot-instructions.md`. | |
| 9 | 9 | |
| 10 | 10 | ## Project Overview |
| 11 | 11 | Full-stack PetClinic application, managing veterinary clinic operations (owners, pets, vets, visits, specialties) |
| Full-stack PetClinic application, managing veterinary clinic operations (owners, | ||
| 14 | 14 | - `petclinic-backend/` - Spring Boot 3.5 REST API (Java 21), Maven-built |
| 15 | 15 | - `petclinic-frontend/` - Angular 16 SPA (Angular Material + Bootstrap 3), npm built |
| 16 | 16 | - `notification-service/` - Spring Boot app on :8090 that texts owners; the backend POSTs to it after every booked visit |
| 17 | -- `petclinic-commons/` - jar both Java apps depend on. standalone build, `mvn install`ed before either app compiles | |
| 17 | +- `petclinic-commons/` - jar both apps depend on, `mvn install`ed first; every call crossing it is traced (AspectJ) | |
| 18 | 18 | |
| 19 | 19 | ## Common Commands |
| 20 | 20 | |
| npm start # Dev server on localhost:4200 | ||
| 44 | 44 | npm run build # Production build |
| 45 | 45 | npm test # Karma tests |
| 46 | 46 | npm run test-headless # Headless Chrome tests |
| 47 | +npm run test:arch # imports adhere to docs/frontend-packages.puml | |
| 47 | 48 | npm run e2e # Protractor e2e tests |
| 48 | 49 | ``` |
| 49 | 50 | |
| Request → REST Controller → Repository / Mapper → JPA Entity | ||
| 65 | 66 | Response ← REST Controller ← Mapper (Entity→DTO) ← Repository |
| 66 | 67 | |
| 67 | 68 | **Key Patterns:** |
| 68 | -- DTOs are hand-written in `src/main/java/.../rest/dto/` (not generated) | |
| 69 | -- `openapi.yaml` at project root is generated output (from `OpenApiExtractorTest`), not a source spec; | |
| 70 | - editing it by hand is denied in `.claude/settings.json` — regenerate it instead. | |
| 71 | - Same deny on `petclinic-frontend/src/app/generated/api-types.ts` and `petclinic-backend/docs/generated/**` | |
| 72 | -- Constructor injection, global exception handling via `@RestControllerAdvice` | |
| 69 | +- DTOs are hand-written (not generated) in `src/main/java/.../rest/dto/` | |
| 70 | +- `/openapi.yaml` is generated (don't edit it!) from Java DTOs & REST Controllers by `OpenApiExtractorTest` | |
| 73 | 71 | |
| 74 | 72 | ## Additional Knowledge |
| 75 | 73 | |
| 76 | 74 | When a guardrail test fails, or a living diagram no longer matches the code, the drift |
| 77 | 75 | checks and what each of them asserts are described in [GUARDRAILS.md](GUARDRAILS.md). |
| 78 | 76 | |
| 79 | -To see how the pieces fit together, every diagram generated from the code is rendered in | |
| 80 | -[ARCHITECTURE.md](ARCHITECTURE.md). | |
| 77 | +Before changing how the layers, the database or the notification call work, read the decisions | |
| 78 | +and their reasons in [ARCHITECTURE.md](ARCHITECTURE.md). | |
| 81 | 79 | |
| 82 | 80 | ### Frontend UX design system |
| 83 | 81 | `petclinic-frontend/src/app/design-system/` holds the standardised widgets. Every |
| which is a different and much cheaper thing: proxy URLs, no runner render, no pu | ||
| 123 | 121 | ### Database |
| 124 | 122 | - **Dev:** Embedded PostgreSQL via `./start-database.sh` (Java jar, localhost:5432) |
| 125 | 123 | - **Tests:** Embedded PostgreSQL (auto-started in-process, no setup needed) |
| 126 | -- **Flyway boots from two locations** (`ddl-auto=none`); an empty DB before that is normal. | |
| 124 | +- **Flyway boots from three locations** (`ddl-auto=none`); an empty DB before that is normal. | |
| 127 | 125 | `db/migration/` is the versioned **schema** chain — no rows here, ever: seed data in a frozen |
| 128 | - migration is what forced the old V5/V7/V8 to patch each other. `db/seed/R__seed.sql` is the | |
| 129 | - **dataset**, repeatable, so edit it freely; it re-runs on checksum change after all versioned | |
| 130 | - migrations, opening with `TRUNCATE ... RESTART IDENTITY` (ids are deterministic — tests rely on | |
| 131 | - owner 1 = Kevin McCallister, pet 3 = Milton). A second dataset = a sibling location reusing the | |
| 132 | - name `R__seed.sql`, picked by swapping `spring.flyway.locations`; Flyway refuses to boot on two | |
| 126 | + migration is what forced the old V5/V7/V8 to patch each other. `db/reference/R__reference.sql` | |
| 127 | + holds the lookup rows every environment needs (pet types, specialties), as a repeatable upsert | |
| 128 | + with literal ids. `db/seed/R__seed.sql` is the demo **dataset** ("Default"), repeatable, so edit | |
| 129 | + it freely; it re-runs on checksum change after all versioned migrations and after the reference | |
| 130 | + rows, opening with `TRUNCATE ... RESTART IDENTITY` (ids are deterministic — owner 1 = Kevin | |
| 131 | + McCallister, pet 3 = Milton). A second dataset = a sibling location reusing the name | |
| 132 | + `R__seed.sql`, picked by swapping `spring.flyway.locations`; Flyway refuses to boot on two | |
| 133 | 133 | resolved migrations sharing one description, so two can never both apply. |
| 134 | +- **Tests start on an empty database** (schema + reference rows), never on the seed: the tests' | |
| 135 | + `src/test/resources/config/application.properties` drops `db/seed` and gives every test method | |
| 136 | + a fresh Zonky copy. A test that reads seeded rows declares it — `@DefaultFixture` (Spring), | |
| 137 | + `test.use({dbFixture: 'default'})` (Playwright), `@fixture:default` (Cucumber). | |
| 134 | 138 | - ⚠️ `./start-database.sh` starts by `rm -rf data`, wiping any rows added at runtime. Use it only |
| 135 | 139 | for a deliberate reset; to keep runtime data, start Postgres from the jar directly. |
| 136 | 140 | |
| Core entities and relationships: | ||
| 153 | 157 | - **User** 1→N **Role** |
| 154 | 158 | |
| 155 | 159 | ## Java Code Style |
| 156 | -- Keep line length < 120 chars | |
| 160 | +- Keep < 120 chars / line | |
| 157 | 161 | - Keep methods under 30 lines |
| 158 | 162 | - Use constructor injection in src/main, `@Autowired` only in tests |
| 159 | -- Use `@Transactional` only when strictly necessary: 2+ DB updates | |
| 163 | +- Use `@Transactional` only when strictly necessary to wrap 2+ DB updates | |
| 160 | 164 | - Global REST exception handling is done via `@RestControllerAdvice` |
| 161 | 165 | - Apply `@Validated` on every `@RequestBody` |
| 162 | -- Write only the `equals`/`hashCode`/`toString` a class actually needs, not all three reflexively | |
| 166 | +- Write `equals`/`hashCode`/`toString` to a class only if it actually needs it | |
| 163 | 167 | |
| 164 | 168 | ## Core Values |
| 165 | 169 | - Write non-trivial code using TDD |
| Core entities and relationships: | ||
| 168 | 172 | - Always run tests after any complex refactoring |
| 169 | 173 | - Be brief |
| 170 | 174 | - Challenge my prompts - I love hearing I'm wrong! Be a thinking partener, not a sycophantic yes-man. |
| 171 | -- Before any git commit, make sure to update any drifted knowledge in AGENTS.md | |
| 175 | +- Before any git commit, make sure to update any drifted knowledge in AGENTS.md, keeping it under 200 lines: move details into separate docs that AGENTS.md links to (progressive disclosure) | |
| 176 | +- An AI-made commit ends with `Co-Authored-By: Claude <Model> (effort: <level>, context: <window>)`, e.g. `Co-Authored-By: Claude Opus 5.5 (effort: high, context: 1M)` — no `<noreply@anthropic.com>` |
| 1 | -# Architecture | |
| 1 | +# Architecture decisions | |
| 2 | 2 | |
| 3 | -Every diagram is **generated from the code** and rendered live via the | |
| 4 | -[PlantUML proxy](https://plantuml.com/) off the GitHub-hosted `.puml` source — | |
| 5 | -each carries a `footer` with its own repo path, so the render is self-identifying. | |
| 3 | +Settled choices, with the context that made them right. Don't undo one as a side effect | |
| 4 | +of a task — if its context no longer holds, say so and ask. | |
| 6 | 5 | |
| 7 | -The two `.drawio.png` diagrams are the exception: drawn by hand, but not therefore | |
| 8 | -unchecked — the mxGraph XML rides inside the picture, and a guardrail test reads it back | |
| 9 | -and compares it against the code. | |
| 10 | - | |
| 11 | -#### Deployment (FE → BE → DB) | |
| 12 | - | |
| 13 | - | |
| 14 | -> Hand-drawn, and it has to be: the backend cannot | |
| 15 | -> introspect the Angular SPA or PostgreSQL. It stays honest anyway — it is a `.drawio.png`, | |
| 16 | -> so the mxGraph XML rides inside the picture (open it in the draw.io desktop app and edit | |
| 17 | -> it in place), and every box and arrow carries metadata that `DeploymentDiagramTest` | |
| 18 | -> checks against the sequence diagrams generated from real traces. | |
| 19 | - | |
| 20 | -#### Domain model | |
| 21 | - | |
| 22 | - | |
| 23 | -#### Conceptual model (hand-arranged) | |
| 24 | - | |
| 25 | - | |
| 26 | -> The same concepts and links as above, but laid out by people and staying where they were | |
| 27 | -> put — which is the whole point, since PlantUML re-arranges its own diagram on every | |
| 28 | -> regeneration and resets everyone's spatial memory of it. `ConceptualModelDiagramTest` owns | |
| 29 | -> the content (every box, line and cardinality must match the domain classes) and never the | |
| 30 | -> positions; a new concept is parked in the staging lane on the left by | |
| 31 | -> `docs/scripts/conceptual-model-patch.py` and keeps the build red until a human places it. | |
| 32 | - | |
| 33 | -#### Database (ER) | |
| 34 | - | |
| 35 | - | |
| 36 | -#### Packages (logical architecture) | |
| 37 | - | |
| 38 | - | |
| 39 | -#### E2E sequence (from real traces) | |
| 40 | - | |
| 41 | - | |
| 42 | -#### C4 — System Context | |
| 43 | - | |
| 44 | - | |
| 45 | -> More C4 views (containers, per-component focus) live in | |
| 46 | -> [`petclinic-backend/docs/README.md`](petclinic-backend/docs/README.md). | |
| 6 | +| Decision | Why, in this context | Rejected | | |
| 7 | +| --- | --- | --- | | |
| 8 | +| No service layer: controllers call repositories and mappers directly | Almost every use case is CRUD with no business logic, so a service layer would just pass calls through | Controller → Service → Repository everywhere; add a service only for a use case with real logic or 2+ writes | | |
| 9 | +| Postgres runs embedded (zonky jar in dev, in-process in tests), no Docker | Trainees' laptops often have no Docker, or a corporate policy blocks it; the app must start with Java alone | Testcontainers, docker-compose | | |
| 10 | +| Notifying the owner is best effort: an HTTP POST to notification-service after the visit is saved; a failure is logged, the booking stands | An SMS outage must not stop the clinic from booking visits | Sending in the same transaction; a message broker (one more thing to run in a training room) | |
| Automated checks against accidental drift, run **locally** in `.githooks/pre-com | ||
| 17 | 17 | | Guardrail | What it protects | How | |
| 18 | 18 | |---|---|---| |
| 19 | 19 | | `PackagesArchTest` | Logical architecture | ArchUnit asserts package deps adhere to `petclinic-backend/docs/packages.puml`, and that the diagram's package set matches the code's subpackages exactly | |
| 20 | +| `packages-arch.test.mjs` | Frontend logical architecture | `npm run test:arch` (`node:test`, no dependencies, run in CI) asserts every relative import between top-level folders of `petclinic-frontend/src/app` is an arrow in `petclinic-frontend/docs/frontend-packages.puml`, and that the diagram's boxes (stereotype = folder relative to `petclinic-frontend/`) match those folders exactly. `*.spec.ts` is skipped, like ArchUnit's `DoNotIncludeTests`; an unused arrow does not fail | | |
| 20 | 21 | | `C3ArchTest` | C4 architecture drift | Parses `docs/c4model.dsl` (stable, human C1/C2), which `!include`s `docs/c4model.c3.dsl` (code-coupled C3); asserts every code package maps to a component and component-to-component deps match the code; re-exports the `*.puml` C4 views | |
| 21 | 22 | | `DeploymentDiagramTest` | Deployment diagram drift | Reads the mxGraph XML embedded in `petclinic-backend/docs/Deployment.drawio.png` (a real draw.io file: one artifact that both renders on GitHub and opens for editing in the desktop app) and compares its arrows against the browser suites' `petclinic-test/generated/*.genseq.puml`, generated from real OpenTelemetry traces (the `.java.*` diagrams in that folder are skipped: a MockMvc `@SpringBootTest` is deployed nowhere). Containers carry `traceParticipant="Browser|Backend|DB|…"` — an external system the generator draws on a quoted lifeline (`"SMS gateway"`) is a box like any other — and arrows carry `traced="yes|no"`. Nothing else on the picture is checked, so nothing else is written on it: no protocol on an arrow, no technology or port under a name. Fails both ways — a traced arrow no trace shows, and a call the traces show that the picture does not draw — plus a third check that no arrow between two traced containers is left undeclared, so a new connection cannot opt out of the guardrail in silence | |
| 22 | 23 | | `ConceptualModelDiagramTest` | Conceptual model drift | Reads the mxGraph XML embedded in `petclinic-backend/docs/ConceptualModel.drawio.png` — the hand-arranged map of the domain, kept alongside the generated `DomainModel.puml` because a regenerated diagram cannot stay in the same place, and people navigate this one from memory. Boxes carry `concept="Owner"`, lines `assoc="Owner-Pet"`; cardinalities are checked against the **visible** end labels — a bare `*` at a many end, nothing at a to-one end — so the picture cannot pass while showing a marker it does not mean, and an unmarked end is asserted to really be to-one. Fails both ways on concepts and on links, rejects a line between two concepts that declares no `assoc`, and fails while any concept still sits untouched in the staging lane `docs/scripts/conceptual-model-patch.py` parked it in — layout is the one thing here a human owns | |
| engineering techniques you can lift into your own projects. | ||
| 16 | 16 | - **Diagrams generated from code, rendered from source.** `.puml` files are |
| 17 | 17 | committed and rendered live via the PlantUML proxy straight off GitHub raw — |
| 18 | 18 | no build step to view them; each render carries a `footer` naming its own |
| 19 | - source (see [ARCHITECTURE.md](ARCHITECTURE.md)). | |
| 19 | + source (see [Architecture diagrams](#architecture-diagrams)). | |
| 20 | 20 | - **C4 model** as versioned Structurizr DSL: stable, human C1/C2 + a |
| 21 | 21 | *code-coupled* C3 that is unit-tested against the real packages. |
| 22 | 22 | - **E2E traces → sequence diagrams.** Tempo/OpenTelemetry spans from a browser |
| engineering techniques you can lift into your own projects. | ||
| 36 | 36 | For example, this domain class diagram is generated from the code by |
| 37 | 37 | `DomainModelExtractorTest` and rendered live from the committed |
| 38 | 38 | [`DomainModel.puml`](petclinic-backend/docs/generated/DomainModel.puml) — see |
| 39 | -[ARCHITECTURE.md](ARCHITECTURE.md) for the rest: | |
| 39 | +[Architecture diagrams](#architecture-diagrams) for the rest: | |
| 40 | 40 | |
| 41 | 41 |  |
| 42 | 42 | |
| B) Manual: Run in separate terminals: | ||
| 51 | 51 | ./start-frontend.sh # ⇒ Angular dev server :4200 |
| 52 | 52 | ./start-grafana.sh # ⇒ :3300 (Requires Docker) |
| 53 | 53 | ``` |
| 54 | +No Docker? `./start-tempo.sh` instead of `./start-grafana.sh` — Tempo alone (one downloaded | |
| 55 | +binary, :3200 + OTLP :4318): no dashboards, but everything the sequence diagrams need. | |
| 54 | 56 | |
| 55 | 57 | - App UI: http://localhost:4200 |
| 56 | 58 | - Backend Swagger: http://localhost:8080/swagger-ui.html |
| B) Manual: Run in separate terminals: | ||
| 59 | 61 | |
| 60 | 62 | Tell an agent running in this repo: |
| 61 | 63 | |
| 62 | -### Context Engineering | |
| 64 | +### Agent Tasks | |
| 63 | 65 | |
| 64 | -- **Trim boilerplate** — remove from AGENTS.md the mvn/npm instructions any LLM already knows. | |
| 65 | -- **Point at generated sources** — replace AGENTS.md's `## API Endpoints` with a pointer to the auto-synced `openapi.yaml`. | |
| 66 | +- **Trim boilerplate from .md** — remove from AGENTS.md the mvn/npm instructions any LLM already knows. | |
| 67 | +- **AGENTS.md: Fix drift** — check it is non-contradictory and in sync with project modules. | |
| 66 | 68 | - **Scope rules by folder** — extract backend rules into a nested `petclinic-backend/AGENTS.md` that loads only there. |
| 67 | -- **Path-scoped skill** — move `### Java Code Style` into a `java/SKILL.md` | |
| 68 | -- **Force load a skill for by file paths**: Add to skill's frontmatter `paths: petclinic-backend/**/*.java`, so it 100% activates before any `.java` edit. | |
| 69 | -- **Reference drift-safe knowledge** — replace the drifting `## Domain Model` chapter with a link to the in-sync `DomainModel.puml`. Add a link to DB.sql and openapi.yaml | |
| 70 | -- **Audit AGENTS.md** — check it is non-contradictory and in sync with recent code changes. | |
| 69 | +- **Prevent .md drift** — replace AGENTS.md's `## API Endpoints` with a pointer to the in-sync `openapi.yaml`. | |
| 70 | +- **AGENTS.md: Reference drift-safe knowledge** — replace the list of REST Endpoints in AGENTS.md with a link to the in-sync `openapi.yaml`. | |
| 71 | +- **Create a skill** — move `### Java Code Style` from AGENTS.md into a `java-code-style/SKILL.md`. Test it activates ±eval on 3 runs. | |
| 72 | +- **Turn skill into rule**: Turn the Java Code Style skill you extracted into a .claude/rules/java-rule.md that is loaded 100% before any .java file is edited. | |
| 73 | +- **Hooks** – create a hook that plays a sound when the agent finished its turn or waits for your answer/approval. | |
| 74 | +- **Scripted guardrail** – remove the rule about < 120 chars and try to manually violate it | |
| 71 | 75 | |
| 72 | -### Tasks | |
| 76 | +### Petclinic Tasks | |
| 73 | 77 | |
| 74 | 78 | The tasks below have the pattern: **<description>** – <prompt to paste to agent> |
| 75 | 79 | |
| 76 | 80 | - **⭐BE+FE feature** – Improve the owner search ([Issue #24](https://github.com/victorrentea/petclinic/issues/24)), then optionally review the code |
| 77 | 81 | - **Fix BE+FE bug** – Fix missing visit date validation ([Issue #40](https://github.com/victorrentea/petclinic/issues/40)): reproduce it in a browser, write a failing Playwright e2e test, then fix the bug so the test passes |
| 78 | 82 | - **Fix UI layout** – align the labels and values in the [owner details screen](http://localhost:4200/petclinic/owners/1) via Playwright screenshots |
| 79 | -- **Exploratory QA** — download the [Playwright test agents](https://playwright.dev/docs/test-agents) and explore the app to write 10 significant automated .feature e2e tests | |
| 80 | -- **Regenerate the [user manual](user-manual/manual.md)** — `/regen-user-manual` | |
| 81 | -- **Grafana dashboard** — create a dashboard of what to monitor, then open it (start Grafana's Docker if needed). | |
| 83 | +- **Regenerate the [user manual](docs/user-manual/manual.md)** — `/regen-user-manual` | |
| 82 | 84 | - **Latency study** — break down the time budget of a "search owners" click from recorded Grafana traces - where is most time lost? |
| 83 | 85 | - **SQL** — export an Excel pie chart of the pet types querying `postgres-db`, and open it when ready. |
| 84 | -- **Query tuning** — optimize the "search owners by last name" query. | |
| 85 | -- **Speedup tests** — speedup the backend tests. | |
| 86 | -- **Rule → guardrail** — replace the AGENTS.md rule "Keep line length < 120 chars" with a script that enforces it on `git push`, over the Java files the push actually changes. | |
| 87 | -- **⭐DevOps drills** — seed a red pipeline, a latency incident or a stale runbook, then drive an agent to green: [`exercises/devops/`](exercises/devops/) | |
| 86 | +- **Query tuning** — optimize the "search owners by last name" flow. | |
| 87 | +- **Tuning tests** — speedup the backend tests (takes lots of time & tokens). | |
| 88 | +- **Exploratory QA** — download the [Playwright test agents](https://playwright.dev/docs/test-agents) and explore the app to write 10 significant automated .feature e2e tests | |
| 89 | +- **Grafana dashboard** — create a dashboard of what to monitor, then open it (start Grafana's Docker if needed). | |
| 88 | 90 | |
| 89 | 91 | Some tasks above require tools from the project's `.mcp.json`, which should autoload when you start the agent in this folder. |
| 90 | 92 | |
| 91 | - | |
| 92 | 93 | ### Tools |
| 93 | 94 | Start YOUR agent in YOUR🫵 work project and tell it: |
| 94 | 95 | |
| 95 | 96 | "Help me grant you access to:..." |
| 96 | -- **Issues** — fetch the issues assigned to me on this git repo, put a test comment on the last one | |
| 97 | -- **CI** — find out how much time the tests took in the last CI run | |
| 98 | -- **Logs** — get the last errors from the dev environment log | |
| 99 | -- **Browser** – reproduce a recent FE bug in a browser | |
| 100 | -- **DB** - which database tables have the most rows in my dev DB? | |
| 101 | -- **Metrics** - What is the endpoint with the highest latency in Grafana? | |
| 102 | -- **Token Saving** - Configure me headroom or at least RTK to save tokens. | |
| 97 | +- **Issues** — proof: fetch the issues assigned to me on this git repo, put a test comment on the last one | |
| 98 | +- **Confluence** — proof: get all pages I've edited/commented / find pages about sick leave procedure | |
| 99 | +- **CI** — proof: find out how much time the tests took in the last CI run | |
| 100 | +- **Logs** — proof: get the last errors from the dev/uat environment log | |
| 101 | +- **Browser** – proof: reproduce in a browser the latest UI bug in my app / explore my app at localhost:... and find 3 critical flows to test | |
| 102 | +- **DB** - proof: which tables have the most rows in my dev DB? | |
| 103 | +- **Metrics** - proof: what is the endpoint with the highest latency in Grafana? | |
| 103 | 104 | |
| 104 | -Patiently guide the agent through this setup, then tell it: Turn the lessons you learned into a reusable skill, ideally scripting as much work you can. | |
| 105 | +Patiently help the agent through this setup - you might need to provide it with an API key. | |
| 106 | +When done, prompt it: Turn the lessons you learned into a reusable skill, scripting as much work you can. | |
| 105 | 107 | |
| 106 | 108 | ### Tools for Agentic Lifestyle |
| 107 | 109 | - [WisprFlow](https://wisprflow.ai) to dictate. |
| 108 | -- [CodexBar](https://codexbar.app) to see the remaining Claude/Codex/Copilot quota. | |
| 109 | -- [ScreenBrush](https://apps.apple.com/us/app/screenbrush/id1233965871) to draw on screen before screenshot to agent. | |
| 110 | -- Custom statusline: [victorrentea/victor-statusline](https://github.com/victorrentea/victor-statusline) — rich status bars for Claude Code and Copilot CLI (per-turn cost, quota burn-rate, prompt-cache health). Tell your CLI agent: *configure yourself a status bar following that repo's README*. | |
| 111 | - | |
| 112 | -### Adopt ideas from `petclinic` into your own project ❤️ | |
| 113 | - | |
| 114 | -Start an agent in **your** project and tell it: | |
| 115 | -From the https://github.com/victorrentea/petclinic repo… | |
| 116 | -- get the mechanism that keeps `packages.puml` in sync with code structure. | |
| 117 | -- get the mechanism that generates `DomainModel.puml` from code. | |
| 118 | -- adopt the database migrations scripts technique | |
| 119 | -- get the mechanism to auto-build `DB.puml` from the incremental DB scripts. | |
| 120 | -- show me where my GitHub Copilot AI credits went — per day, per model and per session — using | |
| 121 | - https://github.com/victorrentea/copilot-usage. | |
| 122 | -- copy the idea to keep the backend Java in sync with `openapi.yaml`, and the frontend `api-types.ts` - prove that a change in a backend Dto fails the FE build, ran automatically prepush and on CI. | |
| 123 | -- get the way agent is kept in a loop to fix CI its push broke. | |
| 124 | -- get how to run critical tests before every push and again remotely in CI. | |
| 125 | -- copy the CODEOWNERS idea to protect critical files behind tech-lead/architect review to prevent dev fatigue-LGTM. | |
| 126 | -- write 3 .feature tests for the most critical flows of my app XYZ | |
| 127 | -- adopt the technique to generate sequence diagrams from key e2e tests, one per scenario, as in `petclinic-test/generated/*.genseq.puml`. | |
| 128 | -- get the code review skill using local sonar scanner and multi-agent review | |
| 129 | -- assemble a reviewer's guide for a change set — diagram deltas, Code City, a video of the | |
| 130 | - feature, complexity delta and snippets deep-linked into the editor — with | |
| 131 | - https://github.com/victorrentea/human-review (`/plugin marketplace add victorrentea/human-review`), | |
| 132 | - which is the `/human-review` skill this repo symlinks into `.claude/skills/`. | |
| 133 | -- set up an End/Stop hook that plays a sound when the agent finishes its turn. | |
| 110 | +- [CodexBar](https://codexbar.app) and/or [AgentsView](https://www.agentsview.io/) to monitor the remaining Claude/Codex/Copilot quota/usage. | |
| 111 | +- [ScreenBrush](https://apps.apple.com/us/app/screenbrush/id1233965871) to draw on screen before a screenshot to agent. | |
| 112 | +- [WalkieTalkie](https://github.com/victorrentea/walkie-talkie) to speak and point/crop/move/select/copy/record. | |
| 113 | +- [Victor's status line](https://github.com/victorrentea/victor-statusline) for Claude Code and Copilot CLI: per-turn cost, quota burn-rate, prompt-cache health, suspend until quota refills | |
| 114 | +- [Human Review](https://github.com/victorrentea/human-review) - helper suite to review AI-gen PRs effectively | |
| 115 | + | |
| 116 | +### Ideas to adopt into your own project ❤️ | |
| 117 | + | |
| 118 | +Start an agent in **your** project and prompt it: | |
| 119 | +From the https://github.com/victorrentea/petclinic repo adopt… | |
| 120 | +- `packages.puml` kept in sync with code structure via tests. | |
| 121 | +- `DomainModel.puml` generated from code via reflection. | |
| 122 | +- `ConceptualModel.drawio.png` unit-tested vs code using reflection. | |
| 123 | +- `DB.puml` built from from the incremental DB scripts. | |
| 124 | +- In-sync Backend Java REST API <=> `openapi.yaml` <=> frontend `api-types.ts`. | |
| 125 | +- Agent auto-fixes the CI after any push. | |
| 126 | +- Running critical tests before every push (githooks/) and again in CI (workflows/). | |
| 127 | +- CODEOWNERS file to protect critical files behind tech-lead/architect review to prevent dev fatigue-LGTM. | |
| 128 | +- Gherkin .feature tests - write 3 for the most critical flows of my app | |
| 129 | +- Generate sequence diagrams from selected e2e tests, as in `petclinic-test/generated/*.genseq.puml`. | |
| 130 | +- assemble a reviewer's guide for a change set — as in https://github.com/victorrentea/human-review | |
| 131 | + | |
| 132 | +## Architecture diagrams | |
| 133 | + | |
| 134 | +Every diagram is **generated from the code** and rendered live via the | |
| 135 | +[PlantUML proxy](https://plantuml.com/) off the GitHub-hosted `.puml` source — | |
| 136 | +each carries a `footer` with its own repo path, so the render is self-identifying. | |
| 137 | + | |
| 138 | +The two `.drawio.png` diagrams are the exception: drawn by hand, but not therefore | |
| 139 | +unchecked — the mxGraph XML rides inside the picture, and a guardrail test reads it back | |
| 140 | +and compares it against the code. | |
| 141 | + | |
| 142 | +### Deployment (FE → BE → DB) | |
| 143 | + | |
| 144 | + | |
| 145 | +> Hand-drawn, and it has to be: the backend cannot | |
| 146 | +> introspect the Angular SPA or PostgreSQL. It stays honest anyway — it is a `.drawio.png`, | |
| 147 | +> so the mxGraph XML rides inside the picture (open it in the draw.io desktop app and edit | |
| 148 | +> it in place), and every box and arrow carries metadata that `DeploymentDiagramTest` | |
| 149 | +> checks against the sequence diagrams generated from real traces. | |
| 150 | + | |
| 151 | +### Domain model | |
| 152 | + | |
| 153 | + | |
| 154 | +### Conceptual model (hand-arranged) | |
| 155 | + | |
| 156 | + | |
| 157 | +> The same concepts and links as above, but laid out by people and staying where they were | |
| 158 | +> put — which is the whole point, since PlantUML re-arranges its own diagram on every | |
| 159 | +> regeneration and resets everyone's spatial memory of it. `ConceptualModelDiagramTest` owns | |
| 160 | +> the content (every box, line and cardinality must match the domain classes) and never the | |
| 161 | +> positions; a new concept is parked in the staging lane on the left by | |
| 162 | +> `docs/scripts/conceptual-model-patch.py` and keeps the build red until a human places it. | |
| 163 | + | |
| 164 | +### Database (ER) | |
| 165 | + | |
| 166 | + | |
| 167 | +### Packages (logical architecture) | |
| 168 | + | |
| 169 | + | |
| 170 | +### E2E sequence (from real traces) | |
| 171 | + | |
| 172 | + | |
| 173 | +### C4 — System Context | |
| 174 | + | |
| 175 | + | |
| 176 | +> More C4 views (containers, per-component focus) live in | |
| 177 | +> [`petclinic-backend/docs/README.md`](petclinic-backend/docs/README.md). |
| 1 | 1 | # One self-contained PetClinic. Every service keeps its default port *inside* the |
| 2 | -# network; only the frontend is published, and to a port the host picks, so several | |
| 3 | -# instances (one per branch under review) can run at once without colliding. | |
| 2 | +# network; the frontend (and, for tools on the host, the backend and the database) are | |
| 3 | +# published to ports the host picks, so several instances (one per branch under review) | |
| 4 | +# can run at once without colliding. | |
| 4 | 5 | # |
| 5 | 6 | # Driven by ../start-docker.sh — it sets COMPOSE_PROJECT_NAME, which is what namespaces |
| 6 | 7 | # containers, network and volumes, and what the reaper matches on to tear one instance |
| services: | ||
| 15 | 16 | POSTGRES_USER: petclinic |
| 16 | 17 | POSTGRES_PASSWORD: petclinic |
| 17 | 18 | POSTGRES_DB: petclinic |
| 19 | + ports: | |
| 20 | + # So DataGrip / DBeaver / psql on the host can look inside a running instance: the | |
| 21 | + # review page's "Running Database" row prints its JDBC URL (`start-docker.sh db`). | |
| 22 | + # Host-picked and loopback-bound like the frontend's, for the same two reasons. | |
| 23 | + - "127.0.0.1::5432" | |
| 18 | 24 | volumes: |
| 19 | 25 | - pgdata:/var/lib/postgresql/data |
| 20 | 26 | healthcheck: |
| services: | ||
| 95 | 101 | # the review page. See the Dockerfile for why they are copied in, not mounted. |
| 96 | 102 | additional_contexts: |
| 97 | 103 | fixtures: ${PETCLINIC_SRC}/petclinic-backend/src/main/resources/db/fixtures |
| 104 | + # The reference rows an "empty" reset (POST /__reset/empty) writes back. | |
| 105 | + reference: ${PETCLINIC_SRC}/petclinic-backend/src/main/resources/db/reference | |
| 98 | 106 | labels: |
| 99 | 107 | ro.victorrentea.petclinic.env: "1" |
| 100 | 108 | environment: |
| COPY reset-server.py /reset-server.py | ||
| 14 | 14 | # exit, so a mount would leave every pinned instance with an empty folder. |
| 15 | 15 | COPY --from=fixtures . /fixtures/ |
| 16 | 16 | |
| 17 | +# The reference rows (pet types, specialties) an "empty" reset writes back after emptying | |
| 18 | +# every table — the same file Flyway runs, so an empty stack and an empty test database | |
| 19 | +# hold the same rows. Named, not globbed: renaming it should fail this build, not leave the | |
| 20 | +# empty reset writing nothing. | |
| 21 | +COPY --from=reference R__reference.sql /reference.sql | |
| 22 | + | |
| 17 | 23 | # Docker copies ownership from the image's directory when it first populates an empty |
| 18 | 24 | # named volume. Without this, /seed arrives root-owned and the unprivileged process |
| 19 | 25 | # cannot write the dump into it. |
| 1 | 1 | #!/usr/bin/env python3 |
| 2 | -"""Put the demo database back to the state Flyway left it in — or to a named fixture. | |
| 2 | +"""Put the demo database back to the state Flyway left it in, to a named fixture, or empty. | |
| 3 | 3 | |
| 4 | 4 | This is deliberately *not* part of the backend, and that is the whole design. A reset |
| 5 | 5 | endpoint that ships inside the application is one misconfigured profile away from being |
| Dropping the schema invalidates those plans and poisons every pooled connection | ||
| 16 | 16 | Hikari retires it, up to half an hour later. |
| 17 | 17 | |
| 18 | 18 | A fixture is a *.sql file baked into /fixtures from petclinic-backend's db/fixtures, next to |
| 19 | -the seed it builds on. It is a delta: POST /<name> truncates, restores the seed, and runs | |
| 20 | -<name>.sql on top, in one transaction — so a fixture that fails leaves the database exactly | |
| 21 | -as it was, not half-emptied. GET / lists them, and the review page draws one button each. | |
| 19 | +the seed. It is a dataset of its own, not a delta on the seed: POST /<name> truncates and | |
| 20 | +runs <name>.sql on the empty tables, in one transaction — so a fixture that fails leaves the | |
| 21 | +database exactly as it was, not half-emptied. POST / restores the seed instead. GET / lists | |
| 22 | +the fixtures, and the review page draws one button each. | |
| 23 | + | |
| 24 | +POST /empty is the state a test that declares no fixture starts on — the e2e suites reset to | |
| 25 | +it before each such test (petclinic-test/src/support/db-fixture.ts), as the Spring tests do: | |
| 26 | +every table emptied, then the reference rows written back (pet types, specialties — | |
| 27 | +db/reference/R__reference.sql, baked in as /reference.sql). Built in rather than a file in | |
| 28 | +/fixtures: it is the absence of a dataset, not one more of them, and gets no button. | |
| 22 | 29 | """ |
| 23 | 30 | import http.server |
| 24 | 31 | import json |
| import threading | ||
| 30 | 37 | |
| 31 | 38 | SEED = "/seed/seed-data.sql" |
| 32 | 39 | FIXTURES = "/fixtures" |
| 40 | +REFERENCE = "/reference.sql" | |
| 41 | +EMPTY = "empty" | |
| 33 | 42 | # On the seed volume, so the page can still say which state it last reset to after this |
| 34 | 43 | # container restarts. "Last reset to", not "is in": a reviewer may have typed since. |
| 35 | 44 | CURRENT = "/seed/current" |
| NAME = re.compile(r"[a-z0-9][a-z0-9-]*") | ||
| 39 | 48 | PSQL = ["psql", "-v", "ON_ERROR_STOP=1", "-q"] |
| 40 | 49 | |
| 41 | 50 | # Every table in public, whatever the schema happens to be by then — a hand-kept list |
| 42 | -# would silently stop covering a table the next migration adds. | |
| 43 | -TRUNCATE = """ | |
| 51 | +# would silently stop covering a table the next migration adds. `{keep}` spares tables | |
| 52 | +# by name. | |
| 53 | +_TRUNCATE = """ | |
| 44 | 54 | DO $$ |
| 45 | 55 | DECLARE stmt text; |
| 46 | 56 | BEGIN |
| BEGIN | ||
| 48 | 58 | || string_agg(format('%I.%I', schemaname, tablename), ', ') |
| 49 | 59 | || ' RESTART IDENTITY CASCADE' |
| 50 | 60 | INTO stmt |
| 51 | - FROM pg_tables WHERE schemaname = 'public'; | |
| 61 | + FROM pg_tables WHERE schemaname = 'public' AND tablename NOT IN ({keep}); | |
| 52 | 62 | IF stmt IS NOT NULL THEN EXECUTE stmt; END IF; |
| 53 | 63 | END $$; |
| 54 | 64 | """ |
| 65 | +# Before the seed: everything, Flyway's history included — the dump puts it back. | |
| 66 | +TRUNCATE = _TRUNCATE.format(keep="''") | |
| 67 | +# Before a fixture: everything but Flyway's history. A fixture is hand-written and does not | |
| 68 | +# carry the history, and a backend that boots onto an empty history over a full schema | |
| 69 | +# refuses to start. | |
| 70 | +TRUNCATE_DATA = _TRUNCATE.format(keep="'flyway_schema_history'") | |
| 55 | 71 | |
| 56 | 72 | _lock = threading.Lock() |
| 57 | 73 | |
| def current(): | ||
| 96 | 112 | |
| 97 | 113 | |
| 98 | 114 | def do_reset(fixture=None): |
| 99 | - args = PSQL + ["--single-transaction", "-c", TRUNCATE, "-f", SEED] | |
| 100 | - if fixture: | |
| 101 | - # pg_dump's output empties search_path for the rest of the session, and the fixture | |
| 102 | - # names its tables unqualified, as any hand-written SQL would. | |
| 103 | - args += ["-c", "SET search_path TO DEFAULT", | |
| 104 | - "-f", os.path.join(FIXTURES, fixture + ".sql")] | |
| 115 | + # A fixture starts from the empty tables, never from the seed: what it shows is only | |
| 116 | + # what its own file writes. RESTART IDENTITY in TRUNCATE puts every id back at 1. | |
| 117 | + if fixture == EMPTY: | |
| 118 | + args = ["-c", TRUNCATE_DATA, "-f", REFERENCE] | |
| 119 | + elif fixture: | |
| 120 | + args = ["-c", TRUNCATE_DATA, "-f", os.path.join(FIXTURES, fixture + ".sql")] | |
| 121 | + else: | |
| 122 | + args = ["-c", TRUNCATE, "-f", SEED] | |
| 123 | + args = PSQL + ["--single-transaction"] + args | |
| 105 | 124 | with _lock: |
| 106 | 125 | r = _run(args) |
| 107 | 126 | if r.returncode != 0: |
| class Handler(http.server.BaseHTTPRequestHandler): | ||
| 123 | 142 | |
| 124 | 143 | def do_POST(self): |
| 125 | 144 | name = self.path.split("?")[0].strip("/") |
| 126 | - if name and name not in fixtures(): | |
| 145 | + if name and name != EMPTY and name not in fixtures(): | |
| 127 | 146 | self._send(404, {"ok": False, "error": "no such fixture: " + name, |
| 128 | 147 | "fixtures": fixtures()}) |
| 129 | 148 | return |
| 1 | +# PetClinic User Manual | |
| 2 | + | |
| 3 | +PetClinic is a small veterinary practice management application. You can use it to keep records of pet owners, the pets in their care, the veterinarians on staff, the specialties those vets practice, and the visits the pets make to the clinic. | |
| 4 | + | |
| 5 | +_This file is regenerated by the `/regen-user-manual` slash command. Do not edit by hand — your changes will be overwritten on the next regeneration._ | |
| 6 | + | |
| 7 | +## Contents | |
| 8 | + | |
| 9 | +- [Getting around](#getting-around) | |
| 10 | +- [Welcome](#welcome) | |
| 11 | +- [Owners](#owners) | |
| 12 | +- [Pets](#pets) | |
| 13 | +- [Visits](#visits) | |
| 14 | +- [Pet Types](#pet-types) | |
| 15 | +- [Veterinarians](#veterinarians) | |
| 16 | +- [Specialties](#specialties) | |
| 17 | + | |
| 18 | +--- | |
| 19 | + | |
| 20 | +## Getting around | |
| 21 | + | |
| 22 | +The top navigation bar is your starting point. *Home* takes you back to the welcome screen. *Owners* and *Veterinarians* are dropdown menus — clicking them reveals shortcuts for searching the existing records and adding new ones. *Visits*, *Pet Types*, and *Specialties* are direct links to those areas. | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | +In the screenshot above, the *Owners* menu has been opened, showing *Search* (the owners list) and *Add New* (the new-owner form). | |
| 27 | + | |
| 28 | +--- | |
| 29 | + | |
| 30 | +## Welcome | |
| 31 | + | |
| 32 | +The welcome screen is what you see when you open PetClinic or click *Home*. It is purely a starting point — no records or actions live here. Use the navigation bar to jump to whichever area you need. | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | +--- | |
| 37 | + | |
| 38 | +## Owners | |
| 39 | + | |
| 40 | +The Owners area is where you keep records for the people who bring pets to the clinic. From here you can search the existing owners, look up a specific owner's pets and visit history, register a new owner, and edit an owner's contact details. | |
| 41 | + | |
| 42 | +### Viewing the list | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | +The list shows every registered owner. Each row includes the address, city, telephone, and the names of the pets associated with that owner. Above the table is a *Last name* search box — type a last name (or any prefix) and click *Find Owner* to narrow the list to matching owners. Click an owner's name in the *Name* column to open their full record. The *Add Owner* button at the bottom of the list opens the new-owner form. | |
| 47 | + | |
| 48 | +### Creating a new owner | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | +Click *Add Owner* on the list page (or *Add New* under the *Owners* menu) to open the registration form. Fill in *First Name*, *Last Name*, *Address*, *City*, and *Telephone* — every field is required. The *Add Owner* button stays disabled until all fields have valid values. Submitting takes you back to the owners list with the new record added. | |
| 53 | + | |
| 54 | +### Looking at an owner's record | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | +Clicking any owner name on the list opens their detail page. The top section shows the owner's contact info with three actions: *Back* returns to the list, *Edit Owner* opens the edit form (see below), and *Add New Pet* registers a pet against this owner. Below that, the *Pets and Visits* section lists each of the owner's pets with their birth date and type, plus per-pet *Edit Pet*, *Delete Pet*, and *Add Visit* buttons. Each pet's visit history follows, with *Edit Visit* and *Delete Visit* actions on every entry. | |
| 59 | + | |
| 60 | +### Editing an owner | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | +From the owner's record click *Edit Owner*. The form is pre-filled with the current values; change what you need and click *Update Owner* to save. *Back* discards your changes and returns to the record. | |
| 65 | + | |
| 66 | +--- | |
| 67 | + | |
| 68 | +## Pets | |
| 69 | + | |
| 70 | +Pets always belong to an owner — there is no standalone "all pets" listing in the staff workflow. You manage a pet through its owner's record: add a new pet from the owner detail screen, then edit, delete, or schedule visits for it from the same place. | |
| 71 | + | |
| 72 | +### Adding a pet to an owner | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | +From the owner's detail page click *Add New Pet*. The form locks the *Owner* field to whoever you came from. Enter the pet's *Name*, pick a *Birth Date* from the calendar picker, and choose the *Type* (cat, dog, lizard, snake, bird, hamster, or horse). Click *Save Pet* to add it to the owner's record. *< Back* returns to the owner without saving. | |
| 77 | + | |
| 78 | +### Editing a pet | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | +Open the owner's detail page and click *Edit Pet* next to the pet you want to change. The same form opens with the current name, birth date, and type pre-filled. Adjust whatever needs changing and click *Save Pet* to update the record; *< Back* discards your changes. To delete a pet instead, click *Delete Pet* on the owner's detail page — it removes the pet and all of its visit history. | |
| 83 | + | |
| 84 | +--- | |
| 85 | + | |
| 86 | +## Visits | |
| 87 | + | |
| 88 | +The Visits area gives you a chronological view of every visit across the clinic. It is the right place to check what's been happening recently or to find a specific visit. To register a new visit, however, you start from a specific pet — see [Pets](#pets) above. | |
| 89 | + | |
| 90 | +### Viewing all visits | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | +The list is sorted with the most recent visit first. Each row shows the visit date, the description of what was done, the pet's name, and a link to the owner's record. Click an owner name to jump to their detail page; from there you can see the visit in the context of that pet's history. | |
| 95 | + | |
| 96 | +### Adding a visit | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | +To add a visit, open the owner's record (see [Owners](#owners)), find the pet that came in for the visit, and click *Add Visit* in that pet's section. The *New Visit* form opens with the pet's identifying details at the top and the pet's previous visits listed below. Pick the *Date* from the calendar, write a *Description* of what was done, and click *Add Visit* to save. The visit appears under that pet on the owner record and in the chronological *Visits* list. The *Add Visit* button at the bottom of the list page only works in the context of a specific pet — always start from the owner's record. | |
| 101 | + | |
| 102 | +--- | |
| 103 | + | |
| 104 | +## Pet Types | |
| 105 | + | |
| 106 | +Pet Types is the list of species the clinic accepts as patients. Whenever you register a new pet, the *Type* dropdown is populated from this list. Edit it sparingly — adding rare types is fine, but renaming or deleting a type changes how every existing pet of that type is shown. | |
| 107 | + | |
| 108 | +### Viewing and editing the list | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | +Each pet type appears as a single row with its name in a read-only field and *Edit* and *Delete* buttons on the right. Click *Edit* to make the name field editable and rename the type, then save. Click *Delete* to remove a type the clinic no longer accepts. *Home* returns to the welcome screen; *Add* opens the new-type form below. | |
| 113 | + | |
| 114 | +### Adding a new pet type | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | +Click *Add* on the Pet Types list. Enter the new type's *Name* in the field — for example, "rabbit" or "ferret" — and click *Save*. The new type immediately becomes available in the *Type* dropdown when registering a pet. | |
| 119 | + | |
| 120 | +--- | |
| 121 | + | |
| 122 | +## Veterinarians | |
| 123 | + | |
| 124 | +The Veterinarians area is where you keep the staff roster. You can browse the current vets along with the specialties they practice, register new vets, edit their assignments, or remove vets who have left the clinic. | |
| 125 | + | |
| 126 | +### Viewing the list | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | +The list shows each veterinarian with their name and the specialties they are qualified in. *Edit Vet* opens the edit form for that vet — useful for changing their specialty assignments. *Delete Vet* removes them from the roster. *Add Vet* at the bottom opens the new-vet form; *Home* returns to the welcome screen. | |
| 131 | + | |
| 132 | +### Adding a new veterinarian | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | +Enter the vet's *First Name* and *Last Name*, then choose a *Type* (specialty) from the dropdown — radiology, surgery, or dentistry. Click *Save Vet* to add them to the roster. *< Back* discards the entry. | |
| 137 | + | |
| 138 | +To assign more than one specialty to a vet, save them with their first specialty here, then open *Edit Vet* from the list to add additional ones. | |
| 139 | + | |
| 140 | +### Editing a veterinarian | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | +From the list click *Edit Vet* next to the vet you want to change. The form pre-fills *First Name*, *Last Name*, and a multi-select *Specialties* picker showing every specialty the vet currently holds. Click the picker to add or remove specialties — this is the screen to use whenever a vet picks up a new qualification or steps away from one. Click *Save Vet* to commit the changes; *< Back* discards them. | |
| 145 | + | |
| 146 | +--- | |
| 147 | + | |
| 148 | +## Specialties | |
| 149 | + | |
| 150 | +Specialties is the list of veterinary disciplines a vet can be qualified in. The list is short by design — radiology, surgery, dentistry — but you can add, rename, or remove specialties to match how your clinic actually works. | |
| 151 | + | |
| 152 | +### Viewing and editing the list | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | +Each specialty appears as a row with its name in a read-only field and *Edit* and *Delete* buttons. Click *Edit* to rename a specialty, then save. *Delete* removes a specialty from the list. Removing a specialty unassigns it from any vet who currently practices it, so be careful before deleting. | |
| 157 | + | |
| 158 | +### Adding a new specialty | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | +Click *Add* at the bottom of the Specialties list. A *New Specialty* form opens directly below the table. Type the name of the new discipline — for example, "cardiology" or "oncology" — and click *Save*. The new specialty becomes available in the *Type* dropdown when adding a vet, and in the multi-select picker when editing one. |
| 1 | 1 | { |
| 2 | - "//": "Configuration for the /human-review plugin (github.com/victorrentea/human-review).", | |
| 3 | - "//2": "The skill itself knows nothing about this project; everything petclinic-specific", | |
| 4 | - "//3": "lives here. A step this file does not describe is skipped and named under the", | |
| 5 | - "//4": "strip on the built page, which is the honest rendering of 'we have no such thing'.", | |
| 2 | + "//": [ | |
| 3 | + "Configuration for the /human-review plugin (github.com/victorrentea/human-review).", | |
| 4 | + "The skill itself knows nothing about this project; everything petclinic-specific", | |
| 5 | + "lives here. A step this file does not describe is skipped and named under the", | |
| 6 | + "strip on the built page, which is the honest rendering of 'we have no such thing'." | |
| 7 | + ], | |
| 6 | 8 | |
| 7 | 9 | "base": "origin/main", |
| 8 | 10 | "spec": "openapi.yaml", |
| 12 | 14 | "drawio": { |
| 13 | 15 | "diagram": "petclinic-backend/docs/ConceptualModel.drawio.png", |
| 14 | 16 | "concepts": "petclinic-backend/docs/generated/DomainModel.puml", |
| 15 | - "//redraw": "conceptual-model-patch.py draws what the code has and the map lacks,", | |
| 16 | - "//redraw2": "in red, and leaves it red until a human lays it out. Named here so the", | |
| 17 | - "//redraw3": "review page can offer to start over: restore the drawing to the base", | |
| 18 | - "//redraw4": "and let the script draw the branch's additions again.", | |
| 17 | + "//redraw": [ | |
| 18 | + "conceptual-model-patch.py draws what the code has and the map lacks,", | |
| 19 | + "in red, and leaves it red until a human lays it out. Named here so the", | |
| 20 | + "review page can offer to start over: restore the drawing to the base", | |
| 21 | + "and let the script draw the branch's additions again." | |
| 22 | + ], | |
| 19 | 23 | "redraw": "python3 petclinic-backend/docs/scripts/conceptual-model-patch.py", |
| 20 | 24 | "name": "conceptual", |
| 21 | 25 | "//tested_against": "What a unit test checks this drawing against; the review page says so under it.", |
| 23 | 27 | } |
| 24 | 28 | }, |
| 25 | 29 | "sequence": { |
| 26 | - "//": "The traced suites run against a stack of their own, never the host's: `up`", | |
| 27 | - "//2": "builds the commit under review WITH its own Grafana LGTM (--otel), so the", | |
| 28 | - "//3": "backend's and the browser's spans land in a Tempo nobody else exports to, and", | |
| 29 | - "//4": "two branches traced at once cannot draw each other's calls. --fresh drops the", | |
| 30 | - "//5": "instance's volumes, so every run starts from the seed: a diagram drawn on", | |
| 31 | - "//6": "yesterday's leftover rows shows queries the code does not make on a clean DB.", | |
| 32 | - "//7": "Its own name (-otel), not video's petclinic-{shortsha}: that one is shared and", | |
| 33 | - "//8": "reused across steps, and has no trace store. `vars` prints every host-picked", | |
| 34 | - "//9": "address as NAME=value — GRAFANA_URL, OTEL_EXPORTER_OTLP_ENDPOINT, BACKEND_URL,", | |
| 35 | - "//10": "BASE_URL, API_BASE_URL — which the step exports to every command: the", | |
| 36 | - "//11": "browser suites and generate.ts read them, the genseq profile's JVM inherits", | |
| 37 | - "//12": "OTEL_EXPORTER_OTLP_ENDPOINT and NOTIFICATIONSERVICE_URL (its visit booking", | |
| 38 | - "//12b": "notifies this instance's service, or AddVisitApiTest's picture loses the", | |
| 39 | - "//12c": "call). `requires` is probed after `up`, with those names", | |
| 40 | - "//13": "expanded: a stack that came up without the store is a skip saying so.", | |
| 30 | + "//": [ | |
| 31 | + "The traced suites run against a stack of their own, never the host's: `up`", | |
| 32 | + "builds the commit under review WITH its own Grafana LGTM (--otel), so the", | |
| 33 | + "backend's and the browser's spans land in a Tempo nobody else exports to, and", | |
| 34 | + "two branches traced at once cannot draw each other's calls. --fresh drops the", | |
| 35 | + "instance's volumes, so every run starts from the seed: a diagram drawn on", | |
| 36 | + "yesterday's leftover rows shows queries the code does not make on a clean DB.", | |
| 37 | + "Its own name (-otel), not video's petclinic-{shortsha}: that one is shared and", | |
| 38 | + "reused across steps, and has no trace store. `vars` prints every host-picked", | |
| 39 | + "address as NAME=value — GRAFANA_URL, OTEL_EXPORTER_OTLP_ENDPOINT, BACKEND_URL,", | |
| 40 | + "BASE_URL, API_BASE_URL — which the step exports to every command: the", | |
| 41 | + "browser suites and generate.ts read them, the genseq profile's JVM inherits", | |
| 42 | + "OTEL_EXPORTER_OTLP_ENDPOINT and NOTIFICATIONSERVICE_URL (its visit booking", | |
| 43 | + "notifies this instance's service, or AddVisitApiTest's picture loses the", | |
| 44 | + "call). `requires` is probed after `up`, with those names", | |
| 45 | + "expanded: a stack that came up without the store is a skip saying so." | |
| 46 | + ], | |
| 41 | 47 | "app": { |
| 42 | 48 | "up": "./start-docker.sh up --ref {sha} --name petclinic-{shortsha}-otel --otel --fresh --ttl 1800", |
| 43 | 49 | "vars": "./start-docker.sh ports petclinic-{shortsha}-otel", |
| 47 | 53 | {"url": "{GRAFANA_URL}/api/health", "what": "Grafana"}, |
| 48 | 54 | {"url": "{OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces", "what": "OTLP collector"} |
| 49 | 55 | ], |
| 50 | - "//14": "`select` adds the tests this branch WROTE or EDITED (test-changes.py's", | |
| 51 | - "//15": "manifest) to the tagged ones, untagged: `{tests.<suite>}` in a command expands", | |
| 52 | - "//16": "to that suite's picks, each spelled as `item` and joined by `join` — the way", | |
| 53 | - "//17": "its runner takes them. GENSEQ_SELECT is read by both browser runners", | |
| 54 | - "//18": "(sequence-tag.ts: <file>::<title>, one per line) and by the JVM's", | |
| 55 | - "//19": "SequenceTraceExtension (Class#method, the -Dtest syntax). The `when: java` run", | |
| 56 | - "//20": "boots a Spring context only when there is a JVM test to trace. Capped at 6", | |
| 57 | - "//21": "(4 browser + 2 JVM) to bound the step's time; a test already tagged takes no slot.", | |
| 56 | + "//select": [ | |
| 57 | + "`select` adds the tests this branch WROTE or EDITED (test-changes.py's", | |
| 58 | + "manifest) to the tagged ones, untagged: `{tests.<suite>}` in a command expands", | |
| 59 | + "to that suite's picks, each spelled as `item` and joined by `join` — the way", | |
| 60 | + "its runner takes them. GENSEQ_SELECT is read by both browser runners", | |
| 61 | + "(sequence-tag.ts: <file>::<title>, one per line) and by the JVM's", | |
| 62 | + "SequenceTraceExtension (Class#method, the -Dtest syntax). The `when: java` run", | |
| 63 | + "boots a Spring context only when there is a JVM test to trace. Capped at 6", | |
| 64 | + "(4 browser + 2 JVM) to bound the step's time; a test already tagged takes no slot." | |
| 65 | + ], | |
| 58 | 66 | "select": { |
| 59 | 67 | "max": 6, |
| 60 | 68 | "suites": { |
| 81 | 89 | ] |
| 82 | 90 | }, |
| 83 | 91 | "c2": { |
| 84 | - "//": "The C4 container view, PROJECTED from the sequence diagrams the step above", | |
| 85 | - "//2": "draws. Nothing here is a drawing, and every key is optional: with no `c2`", | |
| 86 | - "//3": "block at all the tab is still built, from the lifelines and arrows alone.", | |
| 87 | - "//4": "`containers` corrects the only thing a sequence diagram cannot say — what", | |
| 88 | - "//5": "KIND of thing a lifeline is — and names the stack, which is never guessed.", | |
| 89 | - "//6": "`drop` on the Java suite is the important one. A C2 shows what is DEPLOYED,", | |
| 90 | - "//7": "and a @SpringBootTest calling controllers through MockMvc is deployed", | |
| 91 | - "//8": "nowhere: it is a synthetic client, in-process, with no browser, no socket", | |
| 92 | - "//9": "and no server. It draws a fine sequence diagram — that is the Sequence", | |
| 93 | - "//10": "tab's job — and a box here that no operator could point at. The Playwright", | |
| 94 | - "//11": "suite is the opposite case and stays — but it is not what the box is", | |
| 95 | - "//12": "called. The container is the BROWSER running the Angular app; Playwright", | |
| 96 | - "//13": "and Chromium are how this repository happens to drive it in CI, which is", | |
| 97 | - "//14": "a fact about the test rig and not about anything that ships.", | |
| 98 | - "//15": "`Test` -> `as` folds the base branch's older participant name in first, so", | |
| 99 | - "//16": "one `drop` covers a suite that renamed its own lifeline on this branch.", | |
| 100 | - "//17": "`NotificationService` is a container of its own: a separate JVM on :8090,", | |
| 101 | - "//18": "reached over HTTP. Its `SMS gateway` stays external — still a log line.", | |
| 102 | - "//19": "`drawio` is the hand-drawn picture of the same containers, shown under the", | |
| 103 | - "//20": "projected one: arrows a test walked drawn heavy, the rest grey, and a call the", | |
| 104 | - "//21": "traces made that no arrow carries added in dashed red. DeploymentDiagramTest", | |
| 105 | - "//22": "is the guardrail that fails on that red; the page shows where it is.", | |
| 92 | + "//": [ | |
| 93 | + "The C4 container view, PROJECTED from the sequence diagrams the step above", | |
| 94 | + "draws. Nothing here is a drawing, and every key is optional: with no `c2`", | |
| 95 | + "block at all the tab is still built, from the lifelines and arrows alone.", | |
| 96 | + "`containers` corrects the only thing a sequence diagram cannot say — what", | |
| 97 | + "KIND of thing a lifeline is — and names the stack, which is never guessed.", | |
| 98 | + "`drop` on the Java suite is the important one. A C2 shows what is DEPLOYED,", | |
| 99 | + "and a @SpringBootTest calling controllers through MockMvc is deployed", | |
| 100 | + "nowhere: it is a synthetic client, in-process, with no browser, no socket", | |
| 101 | + "and no server. It draws a fine sequence diagram — that is the Sequence", | |
| 102 | + "tab's job — and a box here that no operator could point at. The Playwright", | |
| 103 | + "suite is the opposite case and stays — but it is not what the box is", | |
| 104 | + "called. The container is the BROWSER running the Angular app; Playwright", | |
| 105 | + "and Chromium are how this repository happens to drive it in CI, which is", | |
| 106 | + "a fact about the test rig and not about anything that ships.", | |
| 107 | + "`Test` -> `as` folds the base branch's older participant name in first, so", | |
| 108 | + "one `drop` covers a suite that renamed its own lifeline on this branch.", | |
| 109 | + "`NotificationService` is a container of its own: a separate JVM on :8090,", | |
| 110 | + "reached over HTTP. Its `SMS gateway` stays external — still a log line.", | |
| 111 | + "`drawio` is the hand-drawn picture of the same containers, shown under the", | |
| 112 | + "projected one: arrows a test walked drawn heavy, the rest grey, and a call the", | |
| 113 | + "traces made that no arrow carries added in dashed red. DeploymentDiagramTest", | |
| 114 | + "is the guardrail that fails on that red; the page shows where it is.", | |
| 115 | + "`Commons` is petclinic-commons, a jar linked into both JVMs: its lifeline is", | |
| 116 | + "drawn by ModuleBoundaryTracing for the Sequence tab, and is no container here." | |
| 117 | + ], | |
| 106 | 118 | "drawio": { |
| 107 | 119 | "diagram": "petclinic-backend/docs/Deployment.drawio.png", |
| 108 | 120 | "tested_against": "traced sequence diagrams" |
| 116 | 128 | "Backend": {"kind": "container", "tech": "Java 21 / Spring Boot 3.5"}, |
| 117 | 129 | "NotificationService": {"kind": "container", "tech": "Java 21 / Spring Boot 3.5"}, |
| 118 | 130 | "SMS gateway": {"kind": "external", "tech": "SMS provider (simulated)"}, |
| 131 | + "Commons": {"drop": true}, | |
| 119 | 132 | "DB": {"kind": "db", "tech": "PostgreSQL"} |
| 120 | 133 | } |
| 121 | 134 | }, |
| 122 | 135 | "city": { |
| 123 | - "//": "`tests` is here for the TRACES, not for the city: it is the browser run whose", | |
| 124 | - "//2": "Playwright report the Tests tab harvests, which is why PW_TRACE=on and why the", | |
| 125 | - "//3": "`traces` step below runs nothing of its own. Recording the same suite twice", | |
| 126 | - "//4": "would double the longest step on the page for a second, differently-flaky", | |
| 127 | - "//5": "opinion about the same branch.", | |
| 128 | - "//6": "It used to carry two more commands and two more keys: an instrumented", | |
| 129 | - "//7": "`mvn test`, JACOCO_E2E=1 on the acceptance run, a jacoco:merge, plus", | |
| 130 | - "//8": "`acceptance` and `baseline`. All of that existed for the city's CRAP and", | |
| 131 | - "//9": "coverage colours, which are switched off in code-city itself now (generate.sh", | |
| 132 | - "//10": "step [3]) — the four options stay in its COLOR dropdown marked unavailable.", | |
| 133 | - "//11": "Minutes of Maven and a second Playwright run per review, for two colours", | |
| 134 | - "//12": "nothing draws any more. The committed coverage-baseline.tsv went with them.", | |
| 135 | - "//13": "test-on-own-stack.sh: the suite runs against HEAD's own containers (the same", | |
| 136 | - "//14": "petclinic-<shortsha> instance video.app uses), never against :4200/:8080 — on", | |
| 137 | - "//15": "23 Sep 2026 those belonged to another checkout and three tests failed against", | |
| 138 | - "//16": "a backend from a different branch.", | |
| 139 | - "//17": "COVERAGE_DIR: the same run also measures, per test, which lines each test", | |
| 140 | - "//18": "executes on both sides — Chromium's JS coverage and a JaCoCo dump of the backend", | |
| 141 | - "//19": "(test-on-own-stack.sh starts it with --jacoco). `testcov` below harvests it.", | |
| 136 | + "//": [ | |
| 137 | + "`tests` is here for the TRACES, not for the city: it is the browser run whose", | |
| 138 | + "Playwright report the Tests tab harvests, which is why PW_TRACE=on and why the", | |
| 139 | + "`traces` step below runs nothing of its own. Recording the same suite twice", | |
| 140 | + "would double the longest step on the page for a second, differently-flaky", | |
| 141 | + "opinion about the same branch.", | |
| 142 | + "It used to carry two more commands and two more keys: an instrumented", | |
| 143 | + "`mvn test`, JACOCO_E2E=1 on the acceptance run, a jacoco:merge, plus", | |
| 144 | + "`acceptance` and `baseline`. All of that existed for the city's CRAP and", | |
| 145 | + "coverage colours, which are switched off in code-city itself now (generate.sh", | |
| 146 | + "step [3]) — the four options stay in its COLOR dropdown marked unavailable.", | |
| 147 | + "Minutes of Maven and a second Playwright run per review, for two colours", | |
| 148 | + "nothing draws any more. The committed coverage-baseline.tsv went with them.", | |
| 149 | + "test-on-own-stack.sh: the suite runs against HEAD's own containers (the same", | |
| 150 | + "petclinic-<shortsha> instance video.app uses), never against :4200/:8080 — on", | |
| 151 | + "23 Sep 2026 those belonged to another checkout and three tests failed against", | |
| 152 | + "a backend from a different branch.", | |
| 153 | + "COVERAGE_DIR: the same run also measures, per test, which lines each test", | |
| 154 | + "executes on both sides — Chromium's JS coverage and a JaCoCo dump of the backend", | |
| 155 | + "(test-on-own-stack.sh starts it with --jacoco). `testcov` below harvests it." | |
| 156 | + ], | |
| 142 | 157 | "tests": [ |
| 143 | 158 | "cd petclinic-test && PW_TRACE=on COVERAGE_DIR=../.human-review/coverage ./test-on-own-stack.sh npm test" |
| 144 | 159 | ], |
| 146 | 161 | "title": "Code City" |
| 147 | 162 | }, |
| 148 | 163 | "traces": { |
| 149 | - "//": "Playwright's own recordings of the run, carried onto the page so a reviewer", | |
| 150 | - "//2": "can step through what a test DID without checking the branch out and running", | |
| 151 | - "//3": "the suite again. Rows land in the Tests tab, beside the covering tests.", | |
| 152 | - "//4": "`report` is the html reporter's output directory. Nothing is run from here:", | |
| 153 | - "//5": "the recordings come from the run `city.tests` already does, and the PW_TRACE=on", | |
| 154 | - "//6": "command turns tracing ON (read by playwright.config.ts).", | |
| 155 | - "//7": "The .feature scenarios run under cucumber-js, which writes no Playwright", | |
| 156 | - "//8": "report: src/support/world.ts records one zip + sidecar per scenario into", | |
| 157 | - "//9": "`cucumber` when PW_TRACE=on, and the command below is that run. It needs", | |
| 158 | - "//10": "a running stack (SKIP_SERVER_START); test-on-own-stack.sh gives it the same", | |
| 159 | - "//11": "per-commit instance city.tests just used, not whatever holds :4200.", | |
| 164 | + "//": [ | |
| 165 | + "Playwright's own recordings of the run, carried onto the page so a reviewer", | |
| 166 | + "can step through what a test DID without checking the branch out and running", | |
| 167 | + "the suite again. Rows land in the Tests tab, beside the covering tests.", | |
| 168 | + "`report` is the html reporter's output directory. Nothing is run from here:", | |
| 169 | + "the recordings come from the run `city.tests` already does, and the PW_TRACE=on", | |
| 170 | + "command turns tracing ON (read by playwright.config.ts).", | |
| 171 | + "The .feature scenarios run under cucumber-js, which writes no Playwright", | |
| 172 | + "report: src/support/world.ts records one zip + sidecar per scenario into", | |
| 173 | + "`cucumber` when PW_TRACE=on, and the command below is that run. It needs", | |
| 174 | + "a running stack (SKIP_SERVER_START); test-on-own-stack.sh gives it the same", | |
| 175 | + "per-commit instance city.tests just used, not whatever holds :4200." | |
| 176 | + ], | |
| 160 | 177 | "commands": [ |
| 161 | 178 | "cd petclinic-test && PW_TRACE=on COVERAGE_DIR=../.human-review/coverage ./test-on-own-stack.sh npm run test:cucumber" |
| 162 | 179 | ], |
| 166 | 183 | "limit": 16 |
| 167 | 184 | }, |
| 168 | 185 | "testcov": { |
| 169 | - "//": "Which tests execute which changed lines, measured per test — the right-hand", | |
| 170 | - "//2": "column of the Tests tab. `sources`/`exclude` say what counts as production code;", | |
| 171 | - "//3": "a changed line outside them (a test, a generated file, a doc) is not counted.", | |
| 172 | - "//4": "`junit`: {junit} expands to the skill's JUnit Platform listener on the test", | |
| 173 | - "//5": "classpath; the pom's own jacoco:prepare-agent does the measuring. The backend", | |
| 174 | - "//6": "suite (JUnit and Cucumber together) runs on a throwaway Postgres, never on the", | |
| 175 | - "//7": ":5432 every checkout shares (test-on-own-db.sh says why).", | |
| 176 | - "//8": "`karma`: {karma} expands to the skill's karma config, which loads karma.conf.js.", | |
| 177 | - "//9": "`e2e`: nothing is run — the browser suites already ran for the traces with", | |
| 178 | - "//10": "COVERAGE_DIR (city.tests, traces.commands); this reads what they left there.", | |
| 186 | + "//": [ | |
| 187 | + "Which tests execute which changed lines, measured per test — the right-hand", | |
| 188 | + "column of the Tests tab. `sources`/`exclude` say what counts as production code;", | |
| 189 | + "a changed line outside them (a test, a generated file, a doc) is not counted.", | |
| 190 | + "`junit`: {junit} expands to the skill's JUnit Platform listener on the test", | |
| 191 | + "classpath; the pom's own jacoco:prepare-agent does the measuring. The backend", | |
| 192 | + "suite (JUnit and Cucumber together) runs on a throwaway Postgres, never on the", | |
| 193 | + ":5432 every checkout shares (test-on-own-db.sh says why).", | |
| 194 | + "`karma`: {karma} expands to the skill's karma config, which loads karma.conf.js.", | |
| 195 | + "`e2e`: nothing is run — the browser suites already ran for the traces with", | |
| 196 | + "COVERAGE_DIR (city.tests, traces.commands); this reads what they left there." | |
| 197 | + ], | |
| 179 | 198 | "sources": ["petclinic-backend/src/main/**", "petclinic-frontend/src/app/**"], |
| 180 | 199 | "exclude": ["**/*.spec.ts", "**/generated/**"], |
| 181 | 200 | "junit": [ |
| 206 | 225 | } |
| 207 | 226 | }, |
| 208 | 227 | "video": { |
| 209 | - "//": "`app` is how the run gets the application the film is recorded against, and", | |
| 210 | - "//2": "it exists because the recorder's old liveness check tested the PORT, not the", | |
| 211 | - "//3": "commit: anything answering on :4200 and :8080 was filmed. This machine keeps", | |
| 212 | - "//4": "several checkouts of this repository (petclinic, petclinic-main,", | |
| 213 | - "//5": "petclinic-pr) and each can serve :4200 — so on 17 Sep 2026 a review of", | |
| 214 | - "//6": "test-pr shipped a film of main, narrated from test-pr's diff, with every", | |
| 215 | - "//7": "caption asserting what the frames denied.", | |
| 216 | - "//8": "Now the run owns its own instance: `up` builds the commit under review into", | |
| 217 | - "//9": "containers of its own and prints the host port it got, `down` runs in a", | |
| 218 | - "//10": "finally. {sha}/{shortsha} come from `git rev-parse HEAD`, never from here.", | |
| 219 | - "//11": "--ttl 1800 because this instance exists for one 40-second take; the default", | |
| 220 | - "//12": "two idle hours would keep a whole stack up long after the film was cut.", | |
| 228 | + "//": [ | |
| 229 | + "`app` is how the run gets the application the film is recorded against, and", | |
| 230 | + "it exists because the recorder's old liveness check tested the PORT, not the", | |
| 231 | + "commit: anything answering on :4200 and :8080 was filmed. This machine keeps", | |
| 232 | + "several checkouts of this repository (petclinic, petclinic-main,", | |
| 233 | + "petclinic-pr) and each can serve :4200 — so on 17 Sep 2026 a review of", | |
| 234 | + "test-pr shipped a film of main, narrated from test-pr's diff, with every", | |
| 235 | + "caption asserting what the frames denied.", | |
| 236 | + "Now the run owns its own instance: `up` builds the commit under review into", | |
| 237 | + "containers of its own and prints the host port it got, `down` runs in a", | |
| 238 | + "finally. {sha}/{shortsha} come from `git rev-parse HEAD`, never from here.", | |
| 239 | + "--ttl 1800 because this instance exists for one 40-second take; the default", | |
| 240 | + "two idle hours would keep a whole stack up long after the film was cut." | |
| 241 | + ], | |
| 221 | 242 | "out": ".human-review/assets/feature.webm", |
| 222 | 243 | "app": { |
| 223 | 244 | "up": "./start-docker.sh up --ref {sha} --ttl 1800", |
| 224 | 245 | "url": "./start-docker.sh url petclinic-{shortsha}", |
| 246 | + "//db": "KEY=value lines (JDBC_URL, DB_HOST_PORT, DB_CONTAINER_PORT, DB_USER, DB_PASSWORD): the review page's Running Database row.", | |
| 247 | + "db": "./start-docker.sh db petclinic-{shortsha}", | |
| 225 | 248 | "reset": "/__reset", |
| 226 | 249 | "down": "./start-docker.sh down petclinic-{shortsha}" |
| 227 | 250 | } |
| 231 | 254 | "paths": ["petclinic-backend"] |
| 232 | 255 | }, |
| 233 | 256 | "dsaudit": { |
| 234 | - "//": "The one step that needs BOTH sides of the branch running at the same time,", | |
| 235 | - "//2": "and that is exactly why it never ran. This block used to say `base-new`", | |
| 236 | - "//3": ":4300 and `base-old` :4301 — which is not a configuration, it is a promise", | |
| 237 | - "//4": "about somebody else's machine. Nothing in the pipeline started those two", | |
| 238 | - "//5": "builds, so the prerequisite found nothing answering, the step was skipped on", | |
| 239 | - "//6": "every run, and the UX tab arrived empty explaining that it had — on a branch", | |
| 240 | - "//7": "whose whole subject is the vet field moved onto `app-combo`.", | |
| 241 | - "//8": "`app` is the same block `video.app` is; \"video\" borrows it rather than", | |
| 242 | - "//9": "repeating three commands that would then be two places to forget. The step", | |
| 243 | - "//10": "expands it TWICE — once with HEAD's {sha}/{shortsha} and once with the", | |
| 244 | - "//11": "merge-base's — so the two instances are of this branch and of where it", | |
| 245 | - "//12": "forked, never of whatever a port happens to be serving. Both host ports are", | |
| 246 | - "//13": "picked by the host (that is what lets two builds of one repo be up at once)", | |
| 247 | - "//14": "and read back out of what each `up` printed; both are `down`ed in a finally.", | |
| 257 | + "//": [ | |
| 258 | + "The one step that needs BOTH sides of the branch running at the same time,", | |
| 259 | + "and that is exactly why it never ran. This block used to say `base-new`", | |
| 260 | + ":4300 and `base-old` :4301 — which is not a configuration, it is a promise", | |
| 261 | + "about somebody else's machine. Nothing in the pipeline started those two", | |
| 262 | + "builds, so the prerequisite found nothing answering, the step was skipped on", | |
| 263 | + "every run, and the UX tab arrived empty explaining that it had — on a branch", | |
| 264 | + "whose whole subject is the vet field moved onto `app-combo`.", | |
| 265 | + "`app` is the same block `video.app` is; \"video\" borrows it rather than", | |
| 266 | + "repeating three commands that would then be two places to forget. The step", | |
| 267 | + "expands it TWICE — once with HEAD's {sha}/{shortsha} and once with the", | |
| 268 | + "merge-base's — so the two instances are of this branch and of where it", | |
| 269 | + "forked, never of whatever a port happens to be serving. Both host ports are", | |
| 270 | + "picked by the host (that is what lets two builds of one repo be up at once)", | |
| 271 | + "and read back out of what each `up` printed; both are `down`ed in a finally." | |
| 272 | + ], | |
| 248 | 273 | "app": "video", |
| 249 | 274 | "label-old": "main", |
| 250 | 275 | "source": ["petclinic-frontend/src"], |
| 251 | - "//screens": "EVERY screen of the app, deep-linked to a seeded row, not the two", | |
| 252 | - "//screens2": "somebody guessed the branch touched. The audit shoots them all and", | |
| 253 | - "//screens3": "draws only those whose DOM changed; the rest are named in one line.", | |
| 254 | - "//screens4": "The list was two entries until 19 Sep 2026, which would have looked", | |
| 255 | - "//screens5": "past every list that grew a Vet column — and run-steps.py would have", | |
| 256 | - "//screens6": "said so in red, because it follows each changed component to its route.", | |
| 276 | + "//screens": [ | |
| 277 | + "EVERY screen of the app, deep-linked to a seeded row, not the two", | |
| 278 | + "somebody guessed the branch touched. The audit shoots them all and", | |
| 279 | + "draws only those whose DOM changed; the rest are named in one line.", | |
| 280 | + "The list was two entries until 19 Sep 2026, which would have looked", | |
| 281 | + "past every list that grew a Vet column — and run-steps.py would have", | |
| 282 | + "said so in red, because it follows each changed component to its route." | |
| 283 | + ], | |
| 257 | 284 | "screens": { |
| 258 | 285 | "Welcome": "welcome", |
| 259 | 286 | "Owners": "owners", |
| import org.springframework.web.bind.annotation.PostMapping; | ||
| 6 | 6 | import org.springframework.web.bind.annotation.RequestBody; |
| 7 | 7 | import org.springframework.web.bind.annotation.ResponseStatus; |
| 8 | 8 | import org.springframework.web.bind.annotation.RestController; |
| 9 | +import victor.training.commons.PhoneNumbers; | |
| 9 | 10 | import victor.training.commons.VisitBookedNotification; |
| 10 | 11 | |
| 11 | 12 | @RestController |
| public class NotificationController { | ||
| 19 | 20 | @PostMapping(VisitBookedNotification.PATH) |
| 20 | 21 | @ResponseStatus(HttpStatus.ACCEPTED) |
| 21 | 22 | public void visitBooked(@RequestBody @Validated VisitBookedNotification notification) { |
| 22 | - smsGateway.send(notification.ownerPhone(), "Visit for %s booked on %s. Reply STOP to unsubscribe." | |
| 23 | + String phone = PhoneNumbers.normalize(notification.ownerPhone()); | |
| 24 | + smsGateway.send(phone, "Visit for %s booked on %s. Reply STOP to unsubscribe." | |
| 23 | 25 | .formatted(notification.petName(), notification.visitDate())); |
| 24 | 26 | } |
| 25 | 27 | } |
| down the left-hand side, marked `placed="auto"`, and draws the new line. The tes | ||
| 48 | 48 | to clear the marker. That failure is the point: an automatic layout would be quicker and |
| 49 | 49 | would cost exactly what this diagram is for. |
| 50 | 50 | |
| 51 | +A new line is drawn the same way, by the script and never by hand or by an agent: **one | |
| 52 | +straight red segment** between the two boxes, with no routing, no waypoints, and its `*` | |
| 53 | +near the end it qualifies. If it cuts straight through another box, that is the signal, | |
| 54 | +not a bug: it says "lay me out". A human routes it in draw.io and sets it back to black. | |
| 55 | + | |
| 51 | 56 | ```sh |
| 52 | 57 | python3 docs/scripts/conceptual-model-patch.py --dry-run # what is missing |
| 53 | 58 | python3 docs/scripts/conceptual-model-patch.py # add it, re-render the PNG |
| workspace "PetClinic" "Veterinary practice management system" { | ||
| 19 | 19 | # C3 components + edges — arch-tested vs code |
| 20 | 20 | !include c4model.c3.dsl |
| 21 | 21 | } |
| 22 | - database = container "Database" "Stores all data" "PostgreSQL" | |
| 22 | + database = container "Database" "Stores all data" "PostgreSQL" "Database" | |
| 23 | + notificationService = container "notification-service" "Texts pet owners when a visit is booked" "Java / Spring Boot" | |
| 23 | 24 | } |
| 25 | + smsGateway = softwareSystem "SMS gateway" "Delivers text messages to pet owners" "External" | |
| 24 | 26 | |
| 25 | 27 | petOwner -> petClinic "Manages pets and visits" |
| 26 | 28 | veterinarian -> petClinic "Manages appointments and records" |
| workspace "PetClinic" "Veterinary practice management system" { | ||
| 28 | 30 | veterinarian -> frontend "Uses" |
| 29 | 31 | frontend -> backend "REST API calls" "HTTPS/JSON" |
| 30 | 32 | backend -> database "Reads/writes" "JPA" |
| 33 | + backend -> notificationService "Notifies of booked visits" "HTTP/JSON" | |
| 34 | + notificationService -> smsGateway "Sends SMS" | |
| 35 | + notification -> notificationService "POSTs visit-booked notifications" "HTTP/JSON" | |
| 31 | 36 | } |
| 32 | 37 | |
| 33 | 38 | views { |
| workspace "PetClinic" "Veterinary practice management system" { | ||
| 55 | 60 | include ->mapperLayer-> |
| 56 | 61 | autoLayout |
| 57 | 62 | } |
| 63 | + | |
| 64 | + styles { | |
| 65 | + element "Person" { | |
| 66 | + shape Person | |
| 67 | + background #08427b | |
| 68 | + color #ffffff | |
| 69 | + } | |
| 70 | + element "Software System" { | |
| 71 | + background #1168bd | |
| 72 | + color #ffffff | |
| 73 | + } | |
| 74 | + element "External" { | |
| 75 | + background #999999 | |
| 76 | + color #ffffff | |
| 77 | + } | |
| 78 | + element "Container" { | |
| 79 | + background #438dd5 | |
| 80 | + color #ffffff | |
| 81 | + } | |
| 82 | + element "Database" { | |
| 83 | + shape Cylinder | |
| 84 | + } | |
| 85 | + element "Component" { | |
| 86 | + background #85bbf0 | |
| 87 | + color #000000 | |
| 88 | + } | |
| 89 | + relationship "Relationship" { | |
| 90 | + color #707070 | |
| 91 | + thickness 2 | |
| 92 | + } | |
| 93 | + } | |
| 58 | 94 | } |
| 59 | 95 | } |
| 1 | 1 | @startuml |
| 2 | 2 | |
| 3 | -title Backend Logical Architecture (java packages) | |
| 4 | -caption All boxes and lines are ArchUnit-tested vs code | |
| 5 | -footer petclinic-backend/docs/packages.puml | |
| 3 | +title Java packages and their dependencies | |
| 6 | 4 | |
| 7 | 5 | skinparam shadowing false |
| 8 | 6 | skinparam componentStyle rectangle |
| The conceptual model is hand-arranged on purpose: people remember where things a | ||
| 5 | 5 | here is allowed to move a box a human put somewhere. This script does the two jobs a human |
| 6 | 6 | should not have to do by hand: |
| 7 | 7 | |
| 8 | - * a concept or an association the code has and the map does not is ADDED — every new box | |
| 9 | - in the staging lane down the left-hand side, marked `placed="auto"`, so | |
| 10 | - ConceptualModelDiagramTest keeps failing until someone drags it onto the map proper; | |
| 11 | - * a staged box that has since been moved is GRADUATED — the marker and the staging style | |
| 12 | - come off, and it becomes an ordinary part of the map. | |
| 8 | + * a concept or an association the code has and the map does not is ADDED — every new box | |
| 9 | + in the staging lane down the left-hand side, marked `placed="auto"`, so | |
| 10 | + ConceptualModelDiagramTest keeps failing until someone drags it onto the map proper; | |
| 11 | + every new line as one straight segment from box to box, red, never routed; | |
| 12 | + * a staged box that has since been moved is GRADUATED — the marker and the staging style | |
| 13 | + come off, and it becomes an ordinary part of the map. | |
| 13 | 14 | |
| 14 | 15 | What it never does: reposition, restyle or delete anything else. Removing a concept the |
| 15 | 16 | code dropped is left to a human too, because deleting from a map is a decision about the |
| STAGED_BOX_STYLE = ( | ||
| 59 | 60 | "rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;" |
| 60 | 61 | f"strokeColor={TODO_COLOR};strokeWidth={STROKE_W};dashed=1;fontSize=15;fontStyle=1;") |
| 61 | 62 | EDGE_STYLE = f"endArrow=none;html=1;strokeColor=#333333;strokeWidth={STROKE_W};fontSize=12;" |
| 62 | -# A line this script routed is a to-do like a staged box is: it is joined to the right two | |
| 63 | -# concepts and it is routed by nobody. It turns black when a human has looked at it. | |
| 63 | +# A line this script draws is a to-do like a staged box is: it is joined to the right two | |
| 64 | +# concepts and laid out by nobody. On purpose it is ONE STRAIGHT SEGMENT, centre to centre — | |
| 65 | +# no edgeStyle, no waypoints, no exit/entry anchors — even when that runs it straight through | |
| 66 | +# a box in the way: a line that looks finished is a line nobody lays out. Routing it is a | |
| 67 | +# human's call about the map, made by hand in draw.io; the line turns black once made. | |
| 64 | 68 | STAGED_EDGE_STYLE = ( |
| 65 | 69 | f"endArrow=none;html=1;strokeColor={TODO_COLOR};strokeWidth={STROKE_W};fontSize=12;") |
| 66 | 70 | # the * is the only marker on the map, so it is read at a glance; no white label |
| NOTE_STYLE = f"text;html=1;align=center;fontSize=13;fontColor={TODO_COLOR};" | ||
| 81 | 85 | NOTE_W, NOTE_H = 260, 60 |
| 82 | 86 | NOTE_GAP = 40 # breathing room between the lowest box and the note |
| 83 | 87 | |
| 88 | +# How far along its line an end's * sits: -1 is the source box, 1 the target, so 0.6 puts | |
| 89 | +# it near the end it qualifies — where every hand-drawn line on the map has its marker. | |
| 90 | +LABEL_AT = "0.6" | |
| 91 | + | |
| 84 | 92 | BOX_W, BOX_H = 140, 50 |
| 85 | 93 | LANE_GAP = 300 # how far left of the map the staging lane sits |
| 86 | 94 | ROW_GAP = 90 |
| def patch(model_root, concepts, associations): | ||
| 226 | 234 | changes.append(f"skipped {key} — one of its concepts is not on the map yet") |
| 227 | 235 | continue |
| 228 | 236 | root.extend(edge_cells(key, a)) |
| 229 | - changes.append(f"drew {key} — check how it routes") | |
| 237 | + changes.append(f"drew {key} — a straight red line; lay it out by hand") | |
| 230 | 238 | |
| 231 | 239 | # 4. say, on the map itself, what the red means and how to clear it |
| 232 | 240 | if any(c.startswith(("staged", "drew")) for c in changes) and not note_drawn(cells): |
| def staged_box(name, x, y): | ||
| 297 | 305 | |
| 298 | 306 | def edge_cells(key, a): |
| 299 | 307 | """The line, plus a * at each many end. Nothing is drawn on a to-one end, and the line |
| 300 | - carries no role name: the map shows the shape of the model, not the field names.""" | |
| 308 | + carries no role name: the map shows the shape of the model, not the field names. | |
| 309 | + | |
| 310 | + The line is straight and unrouted (see STAGED_EDGE_STYLE); each * sits on the line near | |
| 311 | + the end it qualifies, at the same relative position the hand-drawn lines use.""" | |
| 301 | 312 | edge_id = "e-" + key.lower() |
| 302 | 313 | obj = ET.Element("object", {"label": "", "assoc": key, "id": edge_id}) |
| 303 | 314 | cell = ET.SubElement(obj, "mxCell", { |
| def edge_cells(key, a): | ||
| 306 | 317 | ET.SubElement(cell, "mxGeometry", {"relative": "1", "as": "geometry"}) |
| 307 | 318 | |
| 308 | 319 | labels = [] |
| 309 | - for suffix, value, position in (("s", a["left_card"], "-0.6"), ("t", a["right_card"], "0.6")): | |
| 320 | + for suffix, value, position in (("s", a["left_card"], f"-{LABEL_AT}"), | |
| 321 | + ("t", a["right_card"], LABEL_AT)): | |
| 310 | 322 | if not value: |
| 311 | 323 | continue # a to-one end is left unmarked |
| 312 | - label = ET.Element("mxCell", {"id": f"{edge_id}-{suffix}", "value": value, | |
| 313 | - "style": LABEL_STYLE, "vertex": "1", | |
| 314 | - "connectable": "0", "parent": edge_id}) | |
| 315 | - ET.SubElement(label, "mxGeometry", {"x": position, "relative": "1", "as": "geometry"}) | |
| 324 | + label = ET.Element("mxCell", { | |
| 325 | + "id": f"{edge_id}-{suffix}", "value": value, "style": LABEL_STYLE, | |
| 326 | + "vertex": "1", "connectable": "0", "parent": edge_id}) | |
| 327 | + geometry = ET.SubElement(label, "mxGeometry", { | |
| 328 | + "x": position, "relative": "1", "as": "geometry"}) | |
| 329 | + ET.SubElement(geometry, "mxPoint", {"as": "offset"}) | |
| 316 | 330 | labels.append(label) |
| 317 | 331 | return [obj] + labels |
| 318 | 332 | |
| def render(model_root): | ||
| 326 | 340 | sys.exit("draw.io CLI not found — install it with: brew install --cask drawio") |
| 327 | 341 | |
| 328 | 342 | body = ET.tostring(model_root, encoding="unicode") |
| 329 | - mxfile = (f'<mxfile host="petclinic-guardrail">' | |
| 330 | - f'<diagram name="Conceptual Model" id="conceptual-model">{body}</diagram></mxfile>') | |
| 343 | + mxfile = ( | |
| 344 | + f'<mxfile host="petclinic-guardrail">' | |
| 345 | + f'<diagram name="Conceptual Model" id="conceptual-model">{body}</diagram></mxfile>') | |
| 331 | 346 | |
| 332 | 347 | with tempfile.TemporaryDirectory() as tmp: |
| 333 | 348 | source = Path(tmp) / "ConceptualModel.drawio" |
| def main(): | ||
| 366 | 381 | render(model_root) |
| 367 | 382 | print(f"\nRe-rendered {DIAGRAM}.") |
| 368 | 383 | if any(c.startswith("staged") for c in changes): |
| 369 | - print("Open it in the draw.io desktop app, drag the orange boxes onto the map, save,\n" | |
| 370 | - "then run this script again to clear their staging markers.") | |
| 384 | + print( | |
| 385 | + "Open it in the draw.io desktop app, drag the red boxes onto the map, save,\n" | |
| 386 | + "then run this script again to clear their staging markers.") | |
| 387 | + if any(c.startswith("drew") for c in changes): | |
| 388 | + print( | |
| 389 | + "Each new line is drawn straight from box to box, through whatever is in the way.\n" | |
| 390 | + "Route it by hand in draw.io and set its colour back to default — that call is\n" | |
| 391 | + "a human's, and neither this script nor an agent makes it.") | |
| 371 | 392 | |
| 372 | 393 | |
| 373 | 394 | if __name__ == "__main__": |
| 3 | 3 | |
| 4 | 4 | This repo has no aggregator/reactor pom at its root (see the comment in |
| 5 | 5 | petclinic-chatbot/pom.xml): petclinic-backend, petclinic-chatbot, petclinic-database |
| 6 | -and refactoring-legacy are four independent, standalone Maven builds, each with its | |
| 6 | +and refactoring-tools are four independent, standalone Maven builds, each with its | |
| 7 | 7 | own external <parent> (spring-boot-starter-parent, or none). "The module graph" is |
| 8 | 8 | therefore a question with a real, checkable answer — either these projects declare |
| 9 | 9 | Maven <dependency> edges on one another, or they don't — and the only trustworthy way |
| def render_puml(projects: list[Project], edges: list[tuple[str, str]], | ||
| 156 | 156 | # puml_diff.py's structural differ, when an element is removed entirely, re-emits it |
| 157 | 157 | # as `component "<s>Name</s>" as Name` — reusing Name as an *unquoted* PlantUML |
| 158 | 158 | # identifier. Maven artifactIds conventionally contain hyphens (petclinic-backend, |
| 159 | - # refactoring-legacy, ...), which are not legal there, so a bracket-form removal | |
| 159 | + # refactoring-tools, ...), which are not legal there, so a bracket-form removal | |
| 160 | 160 | # diff fails to render ("Error line N", confirmed empirically against this file). |
| 161 | 161 | # `component "artifactId" as safe_alias` sidesteps it: the quoted text |
| 162 | 162 | # can be anything, and the differ never needs to turn it into an identifier. |
| def render_puml(projects: list[Project], edges: list[tuple[str, str]], | ||
| 165 | 165 | "@startuml", |
| 166 | 166 | "", |
| 167 | 167 | "title Maven Module Graph", |
| 168 | - "caption Diagram generated from `mvn dependency:tree " | |
| 169 | - "-Dincludes=<this repo's own groupId:artifactId>`", | |
| 170 | - "footer */pom.xml -> petclinic-backend/docs/scripts/mavenmodules/gen-maven-modules.sh " | |
| 171 | - "-> petclinic-backend/docs/generated/MavenModules.puml", | |
| 172 | 168 | "", |
| 173 | 169 | "skinparam shadowing false", |
| 174 | 170 | "skinparam componentStyle rectangle", |
| 8 | 8 | # pipeline: the JVM writes the very same "trace window" the browser suites write, and the very |
| 9 | 9 | # same generator turns windows into diagrams. What differs is only how much has to be running. |
| 10 | 10 | # |
| 11 | -# Needed: ./start-grafana.sh — Tempo + the OTLP collector on :4318, or any other one named by | |
| 12 | -# OTEL_EXPORTER_OTLP_ENDPOINT + GRAFANA_URL (`env $(../start-docker.sh ports N) …` for an | |
| 11 | +# Needed: ./start-grafana.sh — Tempo + the OTLP collector on :4318, or ./start-tempo.sh (Tempo | |
| 12 | +# alone, no Docker), or any other one named by | |
| 13 | +# OTEL_EXPORTER_OTLP_ENDPOINT + GRAFANA_URL / TEMPO_URL (`env $(../start-docker.sh ports N) …` for an | |
| 13 | 14 | # instance up with --otel) |
| 14 | 15 | # NOT needed: the database (the tests boot an embedded Postgres), the backend (they *are* the |
| 15 | 16 | # backend) and the frontend. |
| die() { printf '\033[1;31m[tracing] %s\033[0m\n' "$*" >&2; exit 1; } | ||
| 36 | 37 | # Any HTTP answer at all: a collector GETs back a 405, which is still a collector. |
| 37 | 38 | answers() { curl -s -o /dev/null --max-time 3 "$1"; } |
| 38 | 39 | |
| 39 | -answers "$OTLP/v1/traces" || die "Nothing is listening at $OTLP — start Tempo first: ./start-grafana.sh" | |
| 40 | +answers "$OTLP/v1/traces" || die "Nothing is listening at $OTLP — start Tempo first: ./start-tempo.sh (or ./start-grafana.sh)" | |
| 41 | +# No Grafana named and none on :3300, but ./start-tempo.sh's Tempo is up: the generator fetches | |
| 42 | +# from it directly. | |
| 43 | +if [[ -z "${TEMPO_URL:-}${GRAFANA_URL:-}" ]] && ! curl -fsS -o /dev/null http://localhost:3300/api/health 2>/dev/null \ | |
| 44 | + && curl -fsS -o /dev/null http://127.0.0.1:3200/ready 2>/dev/null; then | |
| 45 | + export TEMPO_URL=http://127.0.0.1:3200 | |
| 46 | +fi | |
| 40 | 47 | log "✅ OTLP collector reachable at $OTLP." |
| 41 | 48 | |
| 42 | 49 | if [[ ! -f "$AGENT_JAR" ]]; then |
| 1 | +package victor.training.petclinic; | |
| 2 | + | |
| 3 | +import io.opentelemetry.api.common.AttributeKey; | |
| 4 | +import io.opentelemetry.api.trace.Span; | |
| 5 | +import org.springframework.lang.Nullable; | |
| 6 | +import org.springframework.stereotype.Component; | |
| 7 | +import org.springframework.transaction.TransactionExecution; | |
| 8 | +import org.springframework.transaction.TransactionExecutionListener; | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * Stamps how each transaction ended on the span of the call that began it. | |
| 12 | + * | |
| 13 | + * <p>The OpenTelemetry agent records a commit (Hibernate's {@code Transaction.commit} span) but | |
| 14 | + * nothing at all for a rollback, so a traced sequence diagram could frame a transaction that | |
| 15 | + * committed and showed none for one that rolled back. Spring calls this after every transaction | |
| 16 | + * it began, while the call that began it (a repository method: nothing here is | |
| 17 | + * {@code @Transactional}) is still the current span; petclinic-test's trace-to-puml.ts writes | |
| 18 | + * the outcome beside that frame's {@code tx}. Spring Boot hands every such listener bean to the | |
| 19 | + * transaction manager it configures. Without the agent the current span is a no-op, and so is | |
| 20 | + * this. | |
| 21 | + */ | |
| 22 | +@Component | |
| 23 | +class TransactionOutcomeTracing implements TransactionExecutionListener { | |
| 24 | + static final AttributeKey<String> OUTCOME = AttributeKey.stringKey("transaction.outcome"); | |
| 25 | + | |
| 26 | + @Override | |
| 27 | + public void afterCommit(TransactionExecution transaction, @Nullable Throwable commitFailure) { | |
| 28 | + Span.current().setAttribute(OUTCOME, commitFailure == null ? "committed" : "commit failed"); | |
| 29 | + } | |
| 30 | + | |
| 31 | + @Override | |
| 32 | + public void afterRollback(TransactionExecution transaction, @Nullable Throwable rollbackFailure) { | |
| 33 | + Span.current().setAttribute(OUTCOME, "rolled back"); | |
| 34 | + } | |
| 35 | +} |
| import io.modelcontextprotocol.spec.McpSchema.ElicitResult; | ||
| 19 | 19 | |
| 20 | 20 | import victor.training.petclinic.domain.Owner; |
| 21 | 21 | import victor.training.petclinic.domain.Pet; |
| 22 | +import victor.training.petclinic.domain.Vet; | |
| 22 | 23 | import victor.training.petclinic.domain.Visit; |
| 23 | 24 | import victor.training.petclinic.repository.OwnerRepository; |
| 24 | 25 | import victor.training.petclinic.repository.PetRepository; |
| import org.springframework.stereotype.Component; | ||
| 9 | 9 | import org.springframework.web.client.RestClient; |
| 10 | 10 | import org.springframework.web.client.RestClientException; |
| 11 | 11 | |
| 12 | +import victor.training.commons.HomeCountry; | |
| 13 | +import victor.training.commons.PhoneNumbers; | |
| 12 | 14 | import victor.training.commons.VisitBookedNotification; |
| 13 | 15 | |
| 14 | 16 | /** POSTs to notification-service; path and payload come from petclinic-commons, shared with it. */ |
| 15 | 17 | @Component |
| 16 | -public class NotificationServiceClient implements NotificationSender { | |
| 18 | +public class NotificationServiceClient implements NotificationSender, HomeCountry { | |
| 17 | 19 | private static final Logger log = LoggerFactory.getLogger(NotificationServiceClient.class); |
| 18 | 20 | |
| 19 | 21 | private final RestClient restClient; |
| public class NotificationServiceClient implements NotificationSender { | ||
| 30 | 32 | try { |
| 31 | 33 | restClient.post() |
| 32 | 34 | .uri(VisitBookedNotification.PATH) |
| 33 | - .body(new VisitBookedNotification(ownerPhone, petName, visitDate)) | |
| 35 | + .body(new VisitBookedNotification(PhoneNumbers.normalize(ownerPhone, this), petName, visitDate)) | |
| 34 | 36 | .retrieve() |
| 35 | 37 | .toBodilessEntity(); |
| 36 | 38 | } catch (RestClientException e) { |
| 37 | 39 | log.warn("Owner not notified of the visit booked for {}: {}", petName, e.getMessage()); |
| 38 | 40 | } |
| 39 | 41 | } |
| 42 | + | |
| 43 | + /** The clinic's own country: an owner's number typed without a prefix is dialled from here. */ | |
| 44 | + @Override | |
| 45 | + public String homeDialCode() { | |
| 46 | + return "+1"; | |
| 47 | + } | |
| 40 | 48 | } |
| public final class ApiExamples { | ||
| 34 | 34 | { "id": 1, "firstName": "George", "lastName": "Franklin", "address": "110 W. Liberty St.", |
| 35 | 35 | "city": "Madison", "telephone": "6085551023", |
| 36 | 36 | "pets": [ { "id": 1, "name": "Leo", "birthDate": "2010-09-07", |
| 37 | - "type": { "id": 1, "name": "cat" }, "ownerId": 1, "visits": [] } ] }, | |
| 37 | + "type": { "id": 1, "name": "cat" }, "ownerId": 1, | |
| 38 | + "visits": [ | |
| 39 | + { "id": 1, "date": "2013-01-01", "description": "neutered", "petId": 1, | |
| 40 | + "vetId": 3, "vetFirstName": "Linda", "vetLastName": "Douglas" }, | |
| 41 | + { "id": 2, "date": "2013-01-02", "description": "annual checkup", "petId": 1 } ] } ] }, | |
| 38 | 42 | { "id": 2, "firstName": "Betty", "lastName": "Davis", "address": "638 Cardinal Ave.", |
| 39 | 43 | "city": "Sun Prairie", "telephone": "6085551749", "pets": [] } |
| 40 | 44 | ]"""; |
| public final class ApiExamples { | ||
| 45 | 49 | "ownerId": 1, "visits": [] }, |
| 46 | 50 | { "id": 2, "name": "Basil", "birthDate": "2012-08-06", "type": { "id": 2, "name": "hamster" }, |
| 47 | 51 | "ownerId": 2, |
| 48 | - "visits": [ { "id": 1, "date": "2013-01-01", "description": "rabies shot", "petId": 2 } ] } | |
| 52 | + "visits": [ { "id": 1, "date": "2013-01-01", "description": "rabies shot", "petId": 2, | |
| 53 | + "vetId": 1, "vetFirstName": "James", "vetLastName": "Carter" } ] } | |
| 49 | 54 | ]"""; |
| 50 | 55 | |
| 51 | 56 | public static final String VISITS = """ |
| 52 | 57 | [ |
| 53 | - { "id": 1, "date": "2013-01-01", "description": "rabies shot", "petId": 7 }, | |
| 58 | + { "id": 1, "date": "2013-01-01", "description": "rabies shot", "petId": 7, | |
| 59 | + "vetId": 1, "vetFirstName": "James", "vetLastName": "Carter" }, | |
| 54 | 60 | { "id": 2, "date": "2013-01-02", "description": "annual checkup", "petId": 8 } |
| 55 | 61 | ]"""; |
| 56 | 62 |
| package victor.training.petclinic.rest; | ||
| 3 | 3 | import java.net.URI; |
| 4 | 4 | import java.util.List; |
| 5 | 5 | |
| 6 | +import org.slf4j.Logger; | |
| 7 | +import org.slf4j.LoggerFactory; | |
| 8 | + | |
| 6 | 9 | import org.springframework.http.ResponseEntity; |
| 7 | 10 | import victor.training.petclinic.mapper.OwnerMapper; |
| 8 | 11 | import victor.training.petclinic.mapper.PetMapper; |
| 9 | 12 | import victor.training.petclinic.mapper.VisitMapper; |
| 10 | 13 | import victor.training.petclinic.domain.Owner; |
| 11 | 14 | import victor.training.petclinic.domain.Pet; |
| 15 | +import victor.training.petclinic.domain.Vet; | |
| 12 | 16 | import victor.training.petclinic.domain.Visit; |
| 13 | 17 | import victor.training.petclinic.notification.NotificationSender; |
| 14 | 18 | import victor.training.petclinic.repository.OwnerRepository; |
| import jakarta.transaction.Transactional; | ||
| 49 | 53 | @PreAuthorize("hasRole(@roles.OWNER_ADMIN)") |
| 50 | 54 | public class OwnerRestController { |
| 51 | 55 | |
| 56 | + private static final Logger log = LoggerFactory.getLogger(OwnerRestController.class); | |
| 57 | + | |
| 52 | 58 | private final OwnerRepository ownerRepository; |
| 53 | 59 | private final PetRepository petRepository; |
| 54 | 60 | private final VisitRepository visitRepository; |
| public class OwnerRestController { | ||
| 189 | 195 | Pet pet = new Pet(); |
| 190 | 196 | pet.setId(petId); |
| 191 | 197 | visit.setPet(pet); |
| 192 | - visit.setVet(vetRepository.findByIdOrNull(visitFieldsDto.getVetId())); | |
| 198 | + Vet vet = vetRepository.findByIdOrNull(visitFieldsDto.getVetId()); | |
| 199 | + visit.setVet(vet); | |
| 193 | 200 | visitRepository.save(visit); |
| 201 | + log.info("Booked visit {} for pet {}", visit.getId(), petId); | |
| 202 | + if (vet != null) { | |
| 203 | + log.debug("Attending vet: {}", vet.getLastName()); | |
| 204 | + } | |
| 194 | 205 | notifyOwner(ownerId, petId, visit); |
| 195 | 206 | return visit.getId(); |
| 196 | 207 | } |
| spring.jpa.properties.hibernate.use_sql_comments=true | ||
| 14 | 14 | |
| 15 | 15 | # Flyway |
| 16 | 16 | spring.flyway.enabled=true |
| 17 | -# Two locations, one chain: db/migration is the incremental schema history (versioned, immutable), | |
| 18 | -# db/seed is the demo/test dataset (repeatable, freely editable, re-applied on checksum change). | |
| 19 | -# Tests inherit this, so a @SpringBootTest gets the same rows the app shows at startup. | |
| 20 | -# A deployment with real data drops the second location (and adds ignore-migration-patterns=*:missing). | |
| 21 | -spring.flyway.locations=classpath:db/migration,classpath:db/seed | |
| 17 | +# Three locations, one chain: db/migration is the incremental schema history (versioned, | |
| 18 | +# immutable, no rows), db/reference the lookup rows every environment needs (pet types, | |
| 19 | +# specialties; repeatable), db/seed the demo dataset (repeatable, freely editable, re-applied | |
| 20 | +# on checksum change). A deployment with real data drops the third location (and adds | |
| 21 | +# ignore-migration-patterns=*:missing). Tests drop it too — src/test/resources/config/ | |
| 22 | +# application.properties — so a test starts on an empty database unless it declares the seed. | |
| 23 | +spring.flyway.locations=classpath:db/migration,classpath:db/reference,classpath:db/seed | |
| 22 | 24 | |
| 23 | 25 | # OpenAPI/Swagger UI |
| 24 | 26 | openapi.info.title=REST Petclinic Backend API |
| 1 | +-- Fixture "minimal": exactly ONE of everything — one owner (Molly Weasley), one pet, one visit, | |
| 2 | +-- one vet with one specialty, one pet type, one user with one role. The smallest dataset on | |
| 3 | +-- which every screen still has something to show, for a demo that must read row by row. | |
| 4 | +-- | |
| 5 | +-- A fixture is a DATASET OF ITS OWN, built on an empty database — never a delta on the seed. | |
| 6 | +-- The reset sidecar (docker/reset/reset-server.py) empties every table, restarting the | |
| 7 | +-- identity sequences, and runs this file on that, in one transaction. So every row minimal | |
| 8 | +-- has is written here, and what R__seed.sql holds never leaks into it. | |
| 9 | +-- | |
| 10 | +-- NOT a Flyway location: spring.flyway.locations names db/migration and db/seed only, so the | |
| 11 | +-- application never runs this. It ships next to the seed so the two read side by side; the | |
| 12 | +-- only thing that executes it is the reset sidecar, which does not exist in production. | |
| 13 | +-- | |
| 14 | +-- The file name is the fixture's name: the review page draws one button per *.sql here. | |
| 15 | +-- | |
| 16 | +-- The ids are literal because the database starts empty with every sequence at 1: the first | |
| 17 | +-- row of each table is id 1. | |
| 18 | + | |
| 19 | +-- Parents before children, so no foreign key ever points at a row not yet written. | |
| 20 | +INSERT INTO types (name) VALUES ('bird'); -- type 1 | |
| 21 | + | |
| 22 | +-- The one vet, Helen Leary, the seed's radiologist: one vet, one specialty, one link. | |
| 23 | +INSERT INTO vets (first_name, last_name) VALUES ('Helen', 'Leary'); -- vet 1 | |
| 24 | + | |
| 25 | +INSERT INTO specialties (name, description) VALUES | |
| 26 | + ('radiology', | |
| 27 | + 'limping, limp, broken bone, fracture, suspected fracture, swollen leg, can''t bear weight, holding up a paw, joint pain after a fall, suspected internal injury, ingested a foreign object.'); | |
| 28 | + | |
| 29 | +INSERT INTO vet_specialties (specialty_id, vet_id) VALUES (1, 1); | |
| 30 | + | |
| 31 | +INSERT INTO owners (first_name, last_name, address, city, telephone) VALUES | |
| 32 | + ('Molly', 'Weasley', 'The Burrow', 'Ottery St Catchpole', '0441404812345'); | |
| 33 | + | |
| 34 | +INSERT INTO pets (name, birth_date, type_id, owner_id) VALUES | |
| 35 | + ('Errol', DATE '2012-03-01', 1, 1); -- the family owl, crashes into windows | |
| 36 | + | |
| 37 | +INSERT INTO visits (pet_id, visit_date, visit_time, description) VALUES | |
| 38 | + (1, DATE '2024-01-18', TIME '11:00', 'concussion after flying into a window'); | |
| 39 | + | |
| 40 | +-- One user, admin, with the seed's password hash and one role. Security is off in the | |
| 41 | +-- demo stack (petclinic.security.enable=false); with it on, OWNER_ADMIN is the role that | |
| 42 | +-- opens the owner, pet and visit screens this dataset is about. | |
| 43 | +INSERT INTO users (username, password, enabled) VALUES | |
| 44 | + ('admin', '$2a$10$ymaklWBnpBKlgdMgkjWVF.GMGyvH8aDuTK.glFOaKw712LHtRRymS', TRUE); -- NOSONAR: test-fixture hash, not a real credential | |
| 45 | + | |
| 46 | +INSERT INTO roles (username, role) VALUES ('admin', 'ROLE_OWNER_ADMIN'); |
| 1 | +-- Reference data: the lookup rows the app cannot be used without, in EVERY environment — | |
| 2 | +-- the app, a deployment with real data, and every test. Pet types (the pet form's dropdown | |
| 3 | +-- has nothing to offer without them) and vet specialties (the chatbot's RAG triages | |
| 4 | +-- symptoms against their descriptions). Nothing else: owners, pets, visits, vets and the | |
| 5 | +-- admin user are demo data and live in db/seed/R__seed.sql, which tests do not load. | |
| 6 | +-- | |
| 7 | +-- Its own location (db/reference), not db/migration: the versioned chain carries no rows, | |
| 8 | +-- ever (AGENTS.md) — the specialty descriptions are exactly the text the old V6/V7 kept | |
| 9 | +-- re-seeding over each other. Repeatable, so edit it freely: it re-runs on checksum change, | |
| 10 | +-- and Flyway runs repeatables by description, so "reference" always lands before "seed". | |
| 11 | +-- | |
| 12 | +-- An upsert, not a TRUNCATE: on a database that already holds these rows (every one the old | |
| 13 | +-- seed built) it rewrites them in place and keeps their ids, which pets and vet_specialties | |
| 14 | +-- point at. The ids are literal for the same reason — the seed's pets name type 1..7 and its | |
| 15 | +-- vets specialty 1..3 — and each sequence is moved past them afterwards, so a type or a | |
| 16 | +-- specialty created through the API never collides with one written here. | |
| 17 | +-- | |
| 18 | +-- The reset sidecar (docker/reset) runs this same file when it empties the database, so an | |
| 19 | +-- "empty" stack and an "empty" test database hold exactly these rows. | |
| 20 | + | |
| 21 | +INSERT INTO types (id, name) VALUES | |
| 22 | + (1, 'cat'), (2, 'dog'), (3, 'lizard'), (4, 'snake'), (5, 'bird'), (6, 'hamster'), (7, 'horse') | |
| 23 | +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name; | |
| 24 | + | |
| 25 | +-- description = the symptoms that identify the specialty; vectorized into the chatbot's RAG. | |
| 26 | +INSERT INTO specialties (id, name, description) VALUES | |
| 27 | + (1, 'radiology', | |
| 28 | + 'limping, limp, broken bone, fracture, suspected fracture, swollen leg, can''t bear weight, holding up a paw, joint pain after a fall, suspected internal injury, ingested a foreign object.'), | |
| 29 | + (2, 'surgery', | |
| 30 | + 'skin rash, itching, lump, growth, mass, wound, open wound, cut, laceration, abscess, swelling under the skin, bite wound, non-healing sore.'), | |
| 31 | + (3, 'dentistry', | |
| 32 | + 'bad breath, tooth pain, bleeding gums, difficulty eating, dropping food, drooling, broken tooth, loose tooth, pawing at the mouth, reluctance to chew.') | |
| 33 | +ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description; | |
| 34 | + | |
| 35 | +SELECT setval(pg_get_serial_sequence('types', 'id'), (SELECT max(id) FROM types)); | |
| 36 | +SELECT setval(pg_get_serial_sequence('specialties', 'id'), (SELECT max(id) FROM specialties)); |
| 1 | +-- Demo dataset ("Default"). NOT part of the incremental chain in db/migration, and NOT | |
| 2 | +-- loaded by the tests: a test starts on an empty database (schema + db/reference) unless it | |
| 3 | +-- declares this dataset — @DefaultFixture on a Spring test, test.use({dbFixture: 'default'}) | |
| 4 | +-- on a Playwright spec, @fixture:default on a Cucumber feature. | |
| 1 | 5 | -- |
| 2 | 6 | -- Repeatable (R__), so Flyway re-applies it whenever its checksum changes and runs it after |
| 7 | +-- every versioned migration — i.e. always against the final schema — and after | |
| 8 | +-- db/reference/R__reference.sql (repeatables run by description), whose pet types and | |
| 9 | +-- specialties the rows below point at by id. That is what frees this file from the "never | |
| 10 | +-- edit an applied migration" rule the old V3__sample_data.sql lived under. | |
| 3 | 11 | -- |
| 4 | 12 | -- To offer a second dataset later, give it the SAME description in a sibling location |
| 5 | 13 | -- (db/seed/minimal/R__seed.sql) and switch spring.flyway.locations between them. Flyway refuses |
| 10 | 15 | |
| 11 | 16 | -- Idempotent, and resets the identity sequences so the ids below stay deterministic |
| 12 | 17 | -- (owner 1 = Kevin McCallister, pet 3 = Milton) — several tests and e2e specs rely on them. |
| 13 | -TRUNCATE roles, users, visits, pets, owners, vet_specialties, specialties, vets, types | |
| 18 | +-- Not types or specialties: those are reference data, never the seed's to empty. | |
| 19 | +TRUNCATE roles, users, visits, pets, owners, vet_specialties, vets | |
| 14 | 20 | RESTART IDENTITY CASCADE; |
| 15 | 21 | |
| 16 | 22 | INSERT INTO vets (first_name, last_name) VALUES |
| INSERT INTO vets (first_name, last_name) VALUES | ||
| 21 | 27 | ('Henry', 'Stevens'), |
| 22 | 28 | ('Sharon', 'Jenkins'); |
| 23 | 29 | |
| 24 | -INSERT INTO specialties (name, description) VALUES | |
| 25 | - ('radiology', | |
| 26 | - 'limping, limp, broken bone, fracture, suspected fracture, swollen leg, can''t bear weight, holding up a paw, joint pain after a fall, suspected internal injury, ingested a foreign object.'), | |
| 27 | - ('surgery', | |
| 28 | - 'skin rash, itching, lump, growth, mass, wound, open wound, cut, laceration, abscess, swelling under the skin, bite wound, non-healing sore.'), | |
| 29 | - ('dentistry', | |
| 30 | - 'bad breath, tooth pain, bleeding gums, difficulty eating, dropping food, drooling, broken tooth, loose tooth, pawing at the mouth, reluctance to chew.'); | |
| 31 | - | |
| 32 | 30 | -- Pairs preserved from upstream petclinic data; column order matches |
| 31 | +-- the original ON CONFLICT (specialty_id, vet_id) hint. The specialties themselves | |
| 32 | +-- (1 radiology, 2 surgery, 3 dentistry) are reference data: db/reference/R__reference.sql. | |
| 33 | 33 | INSERT INTO vet_specialties (specialty_id, vet_id) VALUES |
| 34 | 34 | (1, 2), (2, 3), (3, 3), (2, 4), (1, 5); |
| 35 | 35 | |
| 36 | -INSERT INTO types (name) VALUES | |
| 37 | - ('cat'), ('dog'), ('lizard'), ('snake'), ('bird'), ('hamster'), ('horse'); | |
| 36 | +-- Pet types 1..7 (cat, dog, lizard, snake, bird, hamster, horse) are reference data too. | |
| 38 | 37 | |
| 39 | 38 | -- Owners and pets drawn from European literature, film, and science. |
| 40 | 39 | INSERT INTO owners (first_name, last_name, address, city, telephone) VALUES |
| 41 | - ('Kevin', 'McCallister', '671 Lincoln Boulevard', 'Winnetka', '+18474461990'), | |
| 40 | + ('Kevin', 'McCallister', '671 Lincoln Boulevard', 'Winnetka', '8474461990'), | |
| 42 | 41 | ('Harry', 'Potter', '4 Privet Drive', 'Little Whinging', '0119084455'), |
| 43 | 42 | ('Erwin', 'Schroedinger', 'Boltzmanngasse 5', 'Vienna', '0131914920'), |
| 44 | 43 | ('Ștefan', 'Mureșan', '12 Boltzmanngasse', 'Vienna', '(0043)152634418'), |
| 45 | 44 | ('Łukasz', 'Śliwiński', 'ul. Floriańska 14', 'Kraków', '+48124221357'), |
| 46 | - ('Roger', 'Radcliff', '27 Outer Circle', 'London', '+442074860707'), | |
| 45 | + ('Juan Carlos', 'Martín García Lorca', 'Calle de Alcalá 42', 'Madrid', '0034915312233'), | |
| 47 | 46 | ('Newt', 'Scamander', 'Diagon Alley', 'London', '(0044)2079460001'), |
| 48 | 47 | ('Alice', 'Liddell', 'Christ Church', 'Oxford', '+441865276150'), |
| 49 | 48 | ('Henry', 'Baskerville', 'Baskerville Hall', 'Dartmoor', '(0044)1626832093'), |
| INSERT INTO pets (name, birth_date, type_id, owner_id) VALUES | ||
| 110 | 107 | ('Figaro', DATE '2019-09-09', 1, 23), -- Geppetto's cat |
| 111 | 108 | ('Rocinante', DATE '2015-05-05', 7, 24), -- Don Quixote's horse |
| 112 | 109 | ('Grip', DATE '2023-08-19', 5, 25), -- Dickens's raven |
| 113 | - ('Toby of Lambeth', DATE '2020-11-27', 2, 26); -- Sherlock Holmes's bloodhound | |
| 110 | + ('Toby of Lambeth', DATE '2020-11-27', 2, 26), -- Sherlock Holmes's bloodhound | |
| 111 | + ('Michi', DATE '2021-04-23', 1, 6); -- Juan Carlos's cat: which of his five names is the surname? | |
| 114 | 112 | |
| 115 | 113 | INSERT INTO visits (pet_id, visit_date, description) VALUES |
| 116 | 114 | (15, DATE '2025-11-12', 'rabies vaccine'), |
| 1 | +package victor.training.petclinic; | |
| 2 | + | |
| 3 | +import org.springframework.test.context.jdbc.Sql; | |
| 4 | + | |
| 5 | +import java.lang.annotation.Documented; | |
| 6 | +import java.lang.annotation.ElementType; | |
| 7 | +import java.lang.annotation.Retention; | |
| 8 | +import java.lang.annotation.RetentionPolicy; | |
| 9 | +import java.lang.annotation.Target; | |
| 10 | + | |
| 11 | +/** | |
| 12 | + * The test starts on the Default dataset — the demo seed the app boots with | |
| 13 | + * ({@code db/seed/R__seed.sql}: owner 1 = Kevin McCallister, pet 3 = Milton, the six vets, the | |
| 14 | + * admin user) — instead of the empty database every Spring test gets | |
| 15 | + * ({@code src/test/resources/config/application.properties}). | |
| 16 | + * | |
| 17 | + * <p>Put it on a test that reads a row it did not write. On the class it covers every method; on | |
| 18 | + * a method, only that one. It loads the seed before the test, inside the test's transaction when | |
| 19 | + * there is one (so it rolls back with it); Zonky hands the next test a fresh database either way. | |
| 20 | + * | |
| 21 | + * <p>Declared, not inherited from configuration, so what a test starts on is written on the test | |
| 22 | + * itself — where a reader, and the review page's DB fixture dot, can see it. | |
| 23 | + */ | |
| 24 | +@Documented | |
| 25 | +@Retention(RetentionPolicy.RUNTIME) | |
| 26 | +@Target({ElementType.TYPE, ElementType.METHOD}) | |
| 27 | +@Sql("classpath:db/seed/R__seed.sql") | |
| 28 | +public @interface DefaultFixture { | |
| 29 | +} |
| 1 | +package victor.training.petclinic; | |
| 2 | + | |
| 3 | +import io.zonky.test.db.AutoConfigureEmbeddedDatabase; | |
| 4 | +import org.junit.jupiter.api.MethodOrderer; | |
| 5 | +import org.junit.jupiter.api.Order; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | +import org.junit.jupiter.api.TestMethodOrder; | |
| 8 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 9 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 10 | +import org.springframework.jdbc.core.JdbcTemplate; | |
| 11 | + | |
| 12 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * What a Spring test starts on, pinned down: the schema plus the reference rows when it declares | |
| 16 | + * nothing, the Default seed when it says {@link DefaultFixture} — and never what the test before it | |
| 17 | + * left behind. Not {@code @Transactional} on purpose: a committed row is the one that could leak. | |
| 18 | + */ | |
| 19 | +@SpringBootTest | |
| 20 | +@AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY) | |
| 21 | +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) | |
| 22 | +class StartingDatabaseTest { | |
| 23 | + @Autowired | |
| 24 | + JdbcTemplate jdbc; | |
| 25 | + | |
| 26 | + private int count(String table) { | |
| 27 | + return jdbc.queryForObject("SELECT count(*) FROM " + table, Integer.class); | |
| 28 | + } | |
| 29 | + | |
| 30 | + @Test | |
| 31 | + @Order(1) | |
| 32 | + void aTestThatDeclaresNothingStartsOnAnEmptyDatabase() { | |
| 33 | + for (String table : new String[]{"owners", "pets", "visits", "vets", "vet_specialties", "users", "roles"}) { | |
| 34 | + assertThat(count(table)).as(table).isZero(); | |
| 35 | + } | |
| 36 | + assertThat(jdbc.queryForList("SELECT name FROM types ORDER BY id", String.class)) | |
| 37 | + .as("reference data (db/reference) is there all the same") | |
| 38 | + .containsExactly("cat", "dog", "lizard", "snake", "bird", "hamster", "horse"); | |
| 39 | + assertThat(jdbc.queryForList("SELECT name FROM specialties ORDER BY id", String.class)) | |
| 40 | + .containsExactly("radiology", "surgery", "dentistry"); | |
| 41 | + | |
| 42 | + jdbc.update("INSERT INTO owners (first_name, last_name) VALUES ('Left', 'Behind')"); | |
| 43 | + } | |
| 44 | + | |
| 45 | + @Test | |
| 46 | + @Order(2) | |
| 47 | + void aRowTheTestBeforeCommittedIsGone() { | |
| 48 | + assertThat(count("owners")).isZero(); | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Test | |
| 52 | + @Order(3) | |
| 53 | + @DefaultFixture | |
| 54 | + void aTestThatDeclaresTheDefaultFixtureStartsOnTheSeed() { | |
| 55 | + assertThat(jdbc.queryForObject("SELECT first_name || ' ' || last_name FROM owners WHERE id = 1", String.class)) | |
| 56 | + .isEqualTo("Kevin McCallister"); | |
| 57 | + assertThat(jdbc.queryForObject("SELECT name FROM pets WHERE id = 3", String.class)).isEqualTo("Milton"); | |
| 58 | + assertThat(count("users")).isEqualTo(1); | |
| 59 | + assertThat(count("types")).as("the seed points at the reference rows, it does not repeat them").isEqualTo(7); | |
| 60 | + } | |
| 61 | + | |
| 62 | + @Test | |
| 63 | + @Order(4) | |
| 64 | + void andTheOneAfterItIsEmptyAgain() { | |
| 65 | + assertThat(count("owners")).isZero(); | |
| 66 | + assertThat(count("types")).isEqualTo(7); | |
| 67 | + } | |
| 68 | +} |
| 1 | +package victor.training.petclinic; | |
| 2 | + | |
| 3 | +import io.opentelemetry.api.trace.Span; | |
| 4 | +import io.opentelemetry.context.Context; | |
| 5 | +import io.opentelemetry.context.Scope; | |
| 6 | +import io.zonky.test.db.AutoConfigureEmbeddedDatabase; | |
| 7 | +import org.junit.jupiter.api.BeforeEach; | |
| 8 | +import org.junit.jupiter.api.Test; | |
| 9 | +import org.springframework.beans.factory.annotation.Autowired; | |
| 10 | +import org.springframework.boot.test.context.SpringBootTest; | |
| 11 | +import org.springframework.transaction.PlatformTransactionManager; | |
| 12 | +import org.springframework.transaction.TransactionStatus; | |
| 13 | +import org.springframework.transaction.support.TransactionTemplate; | |
| 14 | +import victor.training.petclinic.repository.OwnerRepository; | |
| 15 | + | |
| 16 | +import static org.mockito.ArgumentMatchers.any; | |
| 17 | +import static org.mockito.Mockito.mock; | |
| 18 | +import static org.mockito.Mockito.never; | |
| 19 | +import static org.mockito.Mockito.verify; | |
| 20 | +import static org.mockito.Mockito.when; | |
| 21 | +import static victor.training.petclinic.TransactionOutcomeTracing.OUTCOME; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * Against the transaction manager Spring Boot builds, so it also proves the listener is | |
| 25 | + * registered on it: a bean nobody hands to the manager would pass a unit test and stamp nothing. | |
| 26 | + */ | |
| 27 | +@SpringBootTest | |
| 28 | +@AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY) | |
| 29 | +class TransactionOutcomeTracingTest { | |
| 30 | + @Autowired | |
| 31 | + PlatformTransactionManager transactionManager; | |
| 32 | + @Autowired | |
| 33 | + OwnerRepository ownerRepository; | |
| 34 | + | |
| 35 | + final Span span = mock(Span.class); | |
| 36 | + | |
| 37 | + @BeforeEach | |
| 38 | + void theMockIsTheCurrentSpan() { | |
| 39 | + when(span.storeInContext(any())).thenCallRealMethod(); | |
| 40 | + } | |
| 41 | + | |
| 42 | + private void inSpan(Runnable call) { | |
| 43 | + try (Scope ignored = Context.root().with(span).makeCurrent()) { | |
| 44 | + call.run(); | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Test | |
| 49 | + void aRepositoryCallStampsCommittedOnItsSpan() { | |
| 50 | + inSpan(() -> ownerRepository.findById(1)); | |
| 51 | + | |
| 52 | + verify(span).setAttribute(OUTCOME, "committed"); | |
| 53 | + } | |
| 54 | + | |
| 55 | + @Test | |
| 56 | + void aTransactionThatRollsBackSaysRolledBack() { | |
| 57 | + inSpan(() -> new TransactionTemplate(transactionManager) | |
| 58 | + .executeWithoutResult(TransactionStatus::setRollbackOnly)); | |
| 59 | + | |
| 60 | + verify(span).setAttribute(OUTCOME, "rolled back"); | |
| 61 | + verify(span, never()).setAttribute(OUTCOME, "committed"); | |
| 62 | + } | |
| 63 | +} |
| public class VetSteps { | ||
| 18 | 18 | @Autowired |
| 19 | 19 | private JdbcTemplate jdbc; |
| 20 | 20 | |
| 21 | + @Given("a vet {string}") | |
| 22 | + public void aVet(String fullName) { | |
| 23 | + insertVet(fullName); | |
| 24 | + } | |
| 25 | + | |
| 21 | 26 | @Given("a vet {string} with specialties {string}") |
| 22 | 27 | public void aVetWithSpecialties(String fullName, String specialty1) { |
| 23 | 28 | insertVet(fullName, specialty1); |
| public class VisitSteps { | ||
| 51 | 51 | .post("/api/visits")); |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | + @When("I book a visit for {string} on {string} with description {string}, choosing vet {string}") | |
| 55 | + public void iBookAVisitChoosingVet(String petName, String date, String description, String vetName) { | |
| 56 | + int petId = http.idOf("pet:" + petName); | |
| 57 | + int vetId = http.idOf("vet:" + vetName); | |
| 58 | + String body = """ | |
| 59 | + {"petId":%d,"date":"%s","description":"%s","vetId":%d} | |
| 60 | + """.formatted(petId, date, description, vetId); | |
| 61 | + | |
| 62 | + http.setLastResponse(RestAssured.given() | |
| 63 | + .baseUri(http.baseUri()) | |
| 64 | + .contentType(ContentType.JSON) | |
| 65 | + .body(body) | |
| 66 | + .post("/api/visits")); | |
| 67 | + } | |
| 68 | + | |
| 69 | + @Then("the booked visit shows vet {string}") | |
| 70 | + public void theBookedVisitShowsVet(String vetName) { | |
| 71 | + String location = http.getLastResponse().getHeader("Location"); | |
| 72 | + var response = RestAssured.given().baseUri(http.baseUri()).get(location); | |
| 73 | + assertThat(response.statusCode()).isEqualTo(200); | |
| 74 | + String[] parts = vetName.split(" ", 2); | |
| 75 | + assertThat(response.jsonPath().getInt("vetId")).isEqualTo(http.idOf("vet:" + vetName)); | |
| 76 | + assertThat(response.jsonPath().getString("vetFirstName")).isEqualTo(parts[0]); | |
| 77 | + assertThat(response.jsonPath().getString("vetLastName")).isEqualTo(parts[1]); | |
| 78 | + } | |
| 79 | + | |
| 54 | 80 | @Then("{string} has {int} visit with description {string}") |
| 55 | 81 | public void petHasVisitsWithDescription(String petName, int expectedCount, String description) { |
| 56 | 82 | int petId = http.idOf("pet:" + petName); |
| import io.opentelemetry.api.GlobalOpenTelemetry; | ||
| 15 | 15 | import io.opentelemetry.api.common.AttributeKey; |
| 16 | 16 | import io.opentelemetry.api.trace.Span; |
| 17 | 17 | import io.opentelemetry.context.Scope; |
| 18 | -import org.junit.jupiter.api.extension.AfterEachCallback; | |
| 19 | -import org.junit.jupiter.api.extension.BeforeEachCallback; | |
| 18 | +import org.junit.jupiter.api.extension.AfterTestExecutionCallback; | |
| 19 | +import org.junit.jupiter.api.extension.BeforeTestExecutionCallback; | |
| 20 | 20 | import org.junit.jupiter.api.extension.ExtensionContext; |
| 21 | 21 | import org.junit.platform.commons.support.AnnotationSupport; |
| 22 | 22 | |
| import org.junit.platform.commons.support.AnnotationSupport; | ||
| 44 | 44 | * <p> |
| 45 | 45 | * Which tests: those carrying {@link GenerateSequence}, and — under <code>-Pgenseq</code>, where the |
| 46 | 46 | * extension is autodetected on every test — those <code>GENSEQ_SELECT</code> names, untagged. |
| 47 | + * <p> | |
| 48 | + * Around the test method's body ({@code BeforeTestExecution}), not around {@code beforeEach}: an | |
| 49 | + * autodetected extension's {@code beforeEach} runs before Spring's, so the span was current while | |
| 50 | + * Spring loaded a test's {@code @Sql} — a {@code @DefaultFixture} seed — and every TRUNCATE and | |
| 51 | + * INSERT of the seed opened the picture, ahead of the first sentence of the test. | |
| 47 | 52 | */ |
| 48 | -public class SequenceTraceExtension implements BeforeEachCallback, AfterEachCallback { | |
| 53 | +public class SequenceTraceExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback { | |
| 49 | 54 | |
| 50 | 55 | /** What the generator searches Tempo by; the same attribute the browser spans carry. */ |
| 51 | 56 | private static final AttributeKey<String> TEST_NAME = AttributeKey.stringKey("test.name"); |
| public class SequenceTraceExtension implements BeforeEachCallback, AfterEachCall | ||
| 82 | 87 | static final String SELECT_ENV = "GENSEQ_SELECT"; |
| 83 | 88 | |
| 84 | 89 | @Override |
| 85 | - public void beforeEach(ExtensionContext context) { | |
| 90 | + public void beforeTestExecution(ExtensionContext context) { | |
| 86 | 91 | // Registered twice on a tagged test — by @GenerateSequence and by autodetection under |
| 87 | 92 | // -Pgenseq — JUnit keeps one; this keeps it one even if it did not. |
| 88 | 93 | if (context.getStore(NAMESPACE).get(Run.class) != null) { |
| public class SequenceTraceExtension implements BeforeEachCallback, AfterEachCall | ||
| 169 | 174 | } |
| 170 | 175 | |
| 171 | 176 | @Override |
| 172 | - public void afterEach(ExtensionContext context) { | |
| 177 | + public void afterTestExecution(ExtensionContext context) { | |
| 173 | 178 | Run run = context.getStore(NAMESPACE).remove(Run.class, Run.class); |
| 174 | 179 | if (run == null) { |
| 175 | 180 | return; |
| class ConceptualModelDiagramTest { | ||
| 84 | 84 | void everyAssociationInTheCodeIsDrawn() throws Exception { |
| 85 | 85 | assertThat(drawnAssociations(diagram()).keySet()) |
| 86 | 86 | .describedAs("Associations between domain classes that %s does not draw. Run " |
| 87 | - + "docs/scripts/conceptual-model-patch.py to add the line, then route it.", | |
| 87 | + + "docs/scripts/conceptual-model-patch.py to add the line: it draws it " | |
| 88 | + + "straight and red, and a human then lays it out by hand.", | |
| 88 | 89 | DIAGRAM) |
| 89 | 90 | .containsAll(expectedAssociations().keySet()); |
| 90 | 91 | } |
| import static org.assertj.core.api.Assertions.assertThat; | ||
| 50 | 50 | * of a call already counted, and self-calls ({@code Backend -> Backend}) are internal. |
| 51 | 51 | * A lifeline whose name is not a bare identifier is quoted by the generator |
| 52 | 52 | * ({@code NotificationService -> "SMS gateway"}) and counts just the same, unquoted: an |
| 53 | - * external system is still a box someone has to draw. | |
| 53 | + * external system is still a box someone has to draw. A library lifeline does not: its calls | |
| 54 | + * never leave the JVM that links it, so they are internal too. | |
| 54 | 55 | */ |
| 55 | 56 | class DeploymentDiagramTest { |
| 56 | 57 | |
| class DeploymentDiagramTest { | ||
| 69 | 70 | */ |
| 70 | 71 | private static final Pattern DEPLOYED_SUITE = Pattern.compile("\\.(?:spec\\.ts|feature)\\..*\\.genseq\\.puml$"); |
| 71 | 72 | |
| 73 | + /** Drawn by petclinic-commons' ModuleBoundaryTracing; deployed inside every Java app, never on its own. */ | |
| 74 | + private static final Set<String> LIBRARIES = Set.of("Commons"); | |
| 75 | + | |
| 72 | 76 | /** A call from one deployed container to another, named as the traces name them. */ |
| 73 | 77 | private record Edge(String from, String to) { |
| 74 | 78 | @Override |
| class DeploymentDiagramTest { | ||
| 188 | 192 | while (m.find()) { |
| 189 | 193 | String from = m.group(1) != null ? m.group(1) : m.group(2); |
| 190 | 194 | String to = m.group(3) != null ? m.group(3) : m.group(4); |
| 191 | - if (!from.equals(to)) { // a self-call stays inside one container | |
| 195 | + boolean insideOneContainer = from.equals(to) || LIBRARIES.contains(from) || LIBRARIES.contains(to); | |
| 196 | + if (!insideOneContainer) { | |
| 192 | 197 | edges.add(new Edge(from, to)); |
| 193 | 198 | } |
| 194 | 199 | } |
| import java.util.Optional; | ||
| 7 | 7 | |
| 8 | 8 | import org.junit.jupiter.api.AfterEach; |
| 9 | 9 | import org.junit.jupiter.api.BeforeEach; |
| 10 | -import org.junit.jupiter.api.DisplayNameGeneration; | |
| 11 | 10 | import org.junit.jupiter.api.Nested; |
| 12 | 11 | import org.junit.jupiter.api.Test; |
| 13 | 12 | import org.junit.jupiter.api.extension.ExtendWith; |
| import victor.training.petclinic.domain.Visit; | ||
| 24 | 23 | import victor.training.petclinic.repository.OwnerRepository; |
| 25 | 24 | import victor.training.petclinic.repository.PetRepository; |
| 26 | 25 | import victor.training.petclinic.repository.VisitRepository; |
| 27 | -import victor.training.petclinic.tools.PrettyTestNames; | |
| 28 | 26 | |
| 29 | 27 | import static org.assertj.core.api.Assertions.assertThat; |
| 30 | 28 | import static org.assertj.core.api.Assertions.assertThatThrownBy; |
| import static org.mockito.Mockito.when; | ||
| 56 | 54 | * Spring context, no database, so the whole class runs in milliseconds. |
| 57 | 55 | */ |
| 58 | 56 | @ExtendWith(MockitoExtension.class) |
| 59 | -@DisplayNameGeneration(PrettyTestNames.class) | |
| 60 | 57 | class CreateVisitShould { |
| 61 | 58 | private static final int OWNER_ID = 7; |
| 62 | 59 | private static final int PET_ID = 13; |
| import org.springframework.transaction.annotation.Transactional; | ||
| 16 | 16 | import victor.training.petclinic.domain.Owner; |
| 17 | 17 | import victor.training.petclinic.domain.Pet; |
| 18 | 18 | import victor.training.petclinic.domain.PetType; |
| 19 | +import victor.training.petclinic.domain.Vet; | |
| 19 | 20 | import victor.training.petclinic.domain.Visit; |
| 20 | 21 | import victor.training.petclinic.repository.OwnerRepository; |
| 21 | 22 | import victor.training.petclinic.repository.PetRepository; |
| 23 | +import victor.training.petclinic.repository.VetRepository; | |
| 22 | 24 | import victor.training.petclinic.repository.VisitRepository; |
| 23 | 25 | |
| 24 | 26 | import static org.assertj.core.api.Assertions.assertThat; |
| 27 | +import static org.assertj.core.api.Assertions.tuple; | |
| 25 | 28 | |
| 26 | 29 | @SpringBootTest |
| 27 | 30 | @AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY) |
| class ListVisitsToolTest { | ||
| 36 | 39 | PetRepository petRepository; |
| 37 | 40 | @Autowired |
| 38 | 41 | VisitRepository visitRepository; |
| 42 | + @Autowired | |
| 43 | + VetRepository vetRepository; | |
| 39 | 44 | |
| 40 | 45 | @AfterEach |
| 41 | 46 | void clearAuth() { |
| 1 | 1 | package victor.training.petclinic.mcp; |
| 2 | 2 | |
| 3 | +import victor.training.petclinic.DefaultFixture; | |
| 3 | 4 | import java.nio.charset.StandardCharsets; |
| 4 | 5 | import java.util.Base64; |
| 5 | 6 | import java.util.Map; |
| import static org.assertj.core.api.Assertions.assertThat; | ||
| 32 | 33 | * user assertion. |
| 33 | 34 | */ |
| 34 | 35 | @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) |
| 36 | +@DefaultFixture // owners 1 and 2 of the seed are the identities the bearer tokens name | |
| 35 | 37 | @AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY) |
| 36 | 38 | class McpStreamableIdentityTest { |
| 37 | 39 |
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. | ||
| 23 | 23 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
| 24 | 24 | |
| 25 | 25 | /** Boots the app against the local dev Postgres reached through the latency proxy started by petclinic-database. |
| 26 | - * Skipped on machines without the proxy on localhost:15432 (e.g. CI). */ | |
| 27 | -@SpringBootTest | |
| 26 | + * Skipped on machines without the proxy on localhost:15432 (e.g. CI). | |
| 27 | + * Reads whatever that database holds and never migrates it: the tests' Flyway config (no seed, | |
| 28 | + * src/test/resources/config/) would refuse a dev database the seed was applied to — and a | |
| 29 | + * test has no business migrating somebody's running app either. */ | |
| 30 | +@SpringBootTest(properties = "spring.flyway.enabled=false") | |
| 28 | 31 | @AutoConfigureMockMvc |
| 29 | 32 | @ExtendWith(JUnitPerfInterceptor.class) |
| 30 | 33 | @EnabledIf("latencyProxyReachable") |
| 1 | 1 | package victor.training.petclinic.rest; |
| 2 | 2 | |
| 3 | +import victor.training.petclinic.DefaultFixture; | |
| 3 | 4 | import static org.assertj.core.api.Assertions.assertThat; |
| 4 | 5 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
| 5 | 6 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
| import java.util.Map; | ||
| 14 | 15 | import java.util.stream.StreamSupport; |
| 15 | 16 | |
| 16 | 17 | import io.zonky.test.db.AutoConfigureEmbeddedDatabase; |
| 17 | -import org.junit.jupiter.api.DisplayNameGeneration; | |
| 18 | 18 | import org.junit.jupiter.api.MethodOrderer; |
| 19 | 19 | import org.junit.jupiter.api.Order; |
| 20 | 20 | import org.junit.jupiter.api.Test; |
| import com.fasterxml.jackson.databind.JsonNode; | ||
| 31 | 31 | import com.fasterxml.jackson.databind.ObjectMapper; |
| 32 | 32 | |
| 33 | 33 | import victor.training.petclinic.genseq.GenerateSequence; |
| 34 | -import victor.training.petclinic.tools.PrettyTestNames; | |
| 35 | 34 | |
| 36 | 35 | /** |
| 37 | 36 | * The same journey as petclinic-test/src/add-visit.spec.ts, one layer down: no Chromium, no Angular, |
| import victor.training.petclinic.tools.PrettyTestNames; | ||
| 52 | 51 | @AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY) |
| 53 | 52 | @AutoConfigureMockMvc |
| 54 | 53 | @WithMockUser(roles = "OWNER_ADMIN") |
| 55 | -@DisplayNameGeneration(PrettyTestNames.class) | |
| 56 | 54 | // The diagram lays its sections out in the order the tests ran, so without a pinned order the |
| 57 | 55 | // picture is JUnit's arbitrary method order — and adding a test reshuffles every section, which |
| 58 | 56 | // the differ can only report as a rewrite. @Order makes the diagram read the way this file does. |
| class AddVisitApiTest { | ||
| 71 | 69 | // every one of them. |
| 72 | 70 | @GenerateSequence |
| 73 | 71 | @Test |
| 72 | + @DefaultFixture // books onto an owner's pet the seed already holds | |
| 74 | 73 | @Order(1) |
| 75 | 74 | void addsAVisitToAnExistingPet() throws Exception { |
| 76 | 75 | given("an owner with at least one pet exists"); |
| public class VisitTest { | ||
| 252 | 252 | }); |
| 253 | 253 | } |
| 254 | 254 | |
| 255 | - @Test | |
| 256 | - void create_withVet() throws Exception { | |
| 257 | - VisitDto newVisit = new VisitDto() | |
| 258 | - .setPetId(petId) | |
| 259 | - .setDate(LocalDate.now()) | |
| 260 | - .setDescription("annual checkup") | |
| 261 | - .setVetId(vet.getId()); | |
| 262 | - | |
| 263 | - String location = mockMvc.perform(post("/api/visits") | |
| 264 | - .content(mapper.writeValueAsString(newVisit)) | |
| 265 | - .contentType(MediaType.APPLICATION_JSON_VALUE)) | |
| 266 | - .andExpect(status().isCreated()) | |
| 267 | - .andReturn().getResponse().getHeader("Location"); | |
| 268 | - | |
| 269 | - VisitDto created = callGet(idFrom(location)); | |
| 270 | - assertThat(created.getVetId()).isEqualTo(vet.getId()); | |
| 271 | - assertThat(created.getVetFirstName()).isEqualTo("James"); | |
| 272 | - assertThat(created.getVetLastName()).isEqualTo("Herriot"); | |
| 273 | - } | |
| 274 | - | |
| 275 | 255 | @Test |
| 276 | 256 | void create_withoutVet() throws Exception { |
| 277 | 257 | VisitDto newVisit = new VisitDto() |
| public class VisitTest { | ||
| 316 | 296 | .content(mapper.writeValueAsString(newVisit)) |
| 317 | 297 | .contentType(MediaType.APPLICATION_JSON_VALUE)) |
| 318 | 298 | .andExpect(status().isCreated()); |
| 319 | - entityManager.flush(); | |
| 320 | - entityManager.clear(); // as a fresh request would: the pet's visits are reloaded | |
| 299 | + flushAndClear(); | |
| 321 | 300 | |
| 322 | 301 | mockMvc.perform(get("/api/owners/{ownerId}", ownerId)) |
| 323 | 302 | .andExpect(status().isOk()) |
| public class VisitTest { | ||
| 347 | 326 | .isNull(); |
| 348 | 327 | } |
| 349 | 328 | |
| 329 | + @Test | |
| 330 | + void bookFromOwnerPage_withUnknownVet_notFound() throws Exception { | |
| 331 | + VisitFieldsDto newVisit = new VisitFieldsDto() | |
| 332 | + .setDate(LocalDate.now()) | |
| 333 | + .setDescription("ear infection") | |
| 334 | + .setVetId(99999); | |
| 335 | + | |
| 336 | + mockMvc.perform(post("/api/owners/{ownerId}/pets/{petId}/visits", ownerId, petId) | |
| 337 | + .content(mapper.writeValueAsString(newVisit)) | |
| 338 | + .contentType(MediaType.APPLICATION_JSON_VALUE)) | |
| 339 | + .andExpect(status().isNotFound()); | |
| 340 | + } | |
| 341 | + | |
| 350 | 342 | @Test |
| 351 | 343 | void update_assignsVet() throws Exception { |
| 352 | 344 | VisitFieldsDto update = new VisitFieldsDto() |
| public class VisitTest { | ||
| 376 | 383 | .content(mapper.writeValueAsString(update)) |
| 377 | 384 | .contentType(MediaType.APPLICATION_JSON_VALUE)) |
| 378 | 385 | .andExpect(status().isOk()); |
| 386 | + flushAndClear(); | |
| 379 | 387 | |
| 380 | 388 | VisitDto updated = callGet(visitId); |
| 381 | 389 | assertThat(updated.getVetId()).isNull(); |
| 1 | 1 | package victor.training.petclinic.security; |
| 2 | 2 | |
| 3 | +import victor.training.petclinic.DefaultFixture; | |
| 3 | 4 | import io.zonky.test.db.AutoConfigureEmbeddedDatabase; |
| 4 | 5 | import org.junit.jupiter.api.Test; |
| 5 | 6 | import org.springframework.beans.factory.annotation.Autowired; |
| class BasicAuthenticationConfigTest { | ||
| 39 | 40 | } |
| 40 | 41 | |
| 41 | 42 | @Test |
| 43 | + @DefaultFixture // logs in as the seed's admin/admin | |
| 42 | 44 | void adminUser_canAccessOwners() throws Exception { |
| 43 | 45 | String credentials = Base64.getEncoder().encodeToString("admin:admin".getBytes()); |
| 44 | 46 | mockMvc.perform(get("/api/owners") |
| import org.junit.jupiter.api.DisplayNameGenerator; | ||
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | 9 | * Turns camelCase class and method names into plain sentences, so a @Nested test class reads as a |
| 10 | - * specification rather than as code. Use via @DisplayNameGeneration on the test class. | |
| 10 | + * specification rather than as code. The default for every test in this module | |
| 11 | + * (src/test/resources/junit-platform.properties), so no class has to opt in. | |
| 11 | 12 | */ |
| 12 | 13 | public class PrettyTestNames extends DisplayNameGenerator.ReplaceUnderscores { |
| 13 | 14 |
| 1 | +package victor.training.petclinic.tools; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.api.BeforeEach; | |
| 6 | +import org.junit.jupiter.api.Test; | |
| 7 | +import org.junit.jupiter.api.TestInfo; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * No @DisplayNameGeneration here on purpose: this class reads as a sentence only because | |
| 11 | + * junit-platform.properties makes PrettyTestNames the default. Drop that line and this fails. | |
| 12 | + */ | |
| 13 | +class PrettyTestNamesTest { | |
| 14 | + | |
| 15 | + private TestInfo info; | |
| 16 | + | |
| 17 | + @BeforeEach | |
| 18 | + void captureTheRunningTest(TestInfo info) { | |
| 19 | + this.info = info; | |
| 20 | + } | |
| 21 | + | |
| 22 | + @Test | |
| 23 | + void everyTestReadsAsASentence_withoutOptingIn() { | |
| 24 | + assertThat(info.getDisplayName()).isEqualTo("every test reads as a sentence without opting in"); | |
| 25 | + } | |
| 26 | +} |
| 1 | +# Every Spring test starts on an EMPTY database: the schema and the reference rows (pet types, | |
| 2 | +# specialties), nothing else. The seed — Kevin, Milton, the vets, the admin user — is not loaded | |
| 3 | +# unless the test declares it: @DefaultFixture (victor.training.petclinic.DefaultFixture), or | |
| 4 | +# @Sql of a db/fixtures/<name>.sql. What a test starts on is then written on the test itself, | |
| 5 | +# which is what the review page's Tests tab draws as the test's DB fixture dot. | |
| 6 | +# | |
| 7 | +# In config/, not beside it: Spring Boot reads classpath:/config/application.properties on top | |
| 8 | +# of classpath:/application.properties, so this overrides two keys and inherits the rest of the | |
| 9 | +# main file. A test-resources application.properties would SHADOW the main one entirely. | |
| 10 | +spring.flyway.locations=classpath:db/migration,classpath:db/reference | |
| 11 | + | |
| 12 | +# A fresh copy of that database for every test method (Zonky clones it from a template | |
| 13 | +# migrated once, ~ms). Without it, a test that commits — a RANDOM_PORT test, a Cucumber | |
| 14 | +# scenario, anything that is not @Transactional — leaves its rows to whichever test comes | |
| 15 | +# next on the same cached context, and "starts empty" would be true only of the first. | |
| 16 | +zonky.test.database.refresh=after_each_test_method |
| Feature: Visits | ||
| 8 | 8 | Then the response status is 201 |
| 9 | 9 | And "Samantha" has 1 visit with description "rabies shot" |
| 10 | 10 | |
| 11 | + Scenario: Book a visit choosing its vet | |
| 12 | + Given a vet "James Herriot" | |
| 13 | + When I book a visit for "Samantha" on "2026-05-10" with description "annual checkup", choosing vet "James Herriot" | |
| 14 | + Then the response status is 201 | |
| 15 | + And the booked visit shows vet "James Herriot" | |
| 16 | + | |
| 11 | 17 | Scenario: Edit a visit description |
| 12 | 18 | Given a visit for "Samantha" on "2026-04-01" described as "checkup" |
| 13 | 19 | When I update that visit's description to "annual checkup" |
| 1 | +# Every JUnit Jupiter test is displayed as a sentence: `create_withoutVet` reads "create without vet". | |
| 2 | +# The display name is what a sequence diagram is titled with (SequenceTraceExtension), so naming it | |
| 3 | +# here — once, for the whole suite — keeps every picture readable without a per-class opt-in. | |
| 4 | +junit.jupiter.displayname.generator.default=victor.training.petclinic.tools.PrettyTestNames |
| 14 | 14 | <properties> |
| 15 | 15 | <maven.compiler.release>21</maven.compiler.release> |
| 16 | 16 | <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> |
| 17 | + <!-- the versions both apps already resolve, Spring Boot's and the OTel annotations' own --> | |
| 18 | + <aspectj.version>1.9.25.1</aspectj.version> | |
| 19 | + <opentelemetry.version>1.49.0</opentelemetry.version> | |
| 20 | + <aspectj-maven-plugin.version>1.14.1</aspectj-maven-plugin.version> | |
| 21 | + <!-- the test stack Spring Boot 3.5.11 pins in both apps --> | |
| 22 | + <junit-jupiter.version>5.12.2</junit-jupiter.version> | |
| 23 | + <assertj.version>3.27.7</assertj.version> | |
| 24 | + <maven-surefire-plugin.version>3.5.4</maven-surefire-plugin.version> | |
| 17 | 25 | </properties> |
| 26 | + | |
| 27 | + <dependencies> | |
| 28 | + <dependency> | |
| 29 | + <groupId>org.aspectj</groupId> | |
| 30 | + <artifactId>aspectjrt</artifactId> | |
| 31 | + <version>${aspectj.version}</version> | |
| 32 | + </dependency> | |
| 33 | + <!-- provided: both apps already carry it, and without the agent it is a no-op anyway --> | |
| 34 | + <dependency> | |
| 35 | + <groupId>io.opentelemetry</groupId> | |
| 36 | + <artifactId>opentelemetry-api</artifactId> | |
| 37 | + <version>${opentelemetry.version}</version> | |
| 38 | + <scope>provided</scope> | |
| 39 | + </dependency> | |
| 40 | + | |
| 41 | + <dependency> | |
| 42 | + <groupId>org.junit.jupiter</groupId> | |
| 43 | + <artifactId>junit-jupiter</artifactId> | |
| 44 | + <version>${junit-jupiter.version}</version> | |
| 45 | + <scope>test</scope> | |
| 46 | + </dependency> | |
| 47 | + <dependency> | |
| 48 | + <groupId>io.opentelemetry</groupId> | |
| 49 | + <artifactId>opentelemetry-sdk-testing</artifactId> | |
| 50 | + <version>${opentelemetry.version}</version> | |
| 51 | + <scope>test</scope> | |
| 52 | + </dependency> | |
| 53 | + <dependency> | |
| 54 | + <groupId>org.assertj</groupId> | |
| 55 | + <artifactId>assertj-core</artifactId> | |
| 56 | + <version>${assertj.version}</version> | |
| 57 | + <scope>test</scope> | |
| 58 | + </dependency> | |
| 59 | + </dependencies> | |
| 60 | + | |
| 61 | + <build> | |
| 62 | + <plugins> | |
| 63 | + <!-- Weaves ModuleBoundaryTracing into this jar's own classes at build time: the apps | |
| 64 | + get the spans with no agent, flag or build change of their own. --> | |
| 65 | + <plugin> | |
| 66 | + <groupId>dev.aspectj</groupId> | |
| 67 | + <artifactId>aspectj-maven-plugin</artifactId> | |
| 68 | + <version>${aspectj-maven-plugin.version}</version> | |
| 69 | + <dependencies> | |
| 70 | + <dependency> | |
| 71 | + <groupId>org.aspectj</groupId> | |
| 72 | + <artifactId>aspectjtools</artifactId> | |
| 73 | + <version>${aspectj.version}</version> | |
| 74 | + </dependency> | |
| 75 | + </dependencies> | |
| 76 | + <configuration> | |
| 77 | + <complianceLevel>21</complianceLevel> | |
| 78 | + <encoding>UTF-8</encoding> | |
| 79 | + </configuration> | |
| 80 | + <executions> | |
| 81 | + <execution> | |
| 82 | + <goals> | |
| 83 | + <goal>compile</goal> | |
| 84 | + </goals> | |
| 85 | + </execution> | |
| 86 | + </executions> | |
| 87 | + </plugin> | |
| 88 | + <plugin> | |
| 89 | + <groupId>org.apache.maven.plugins</groupId> | |
| 90 | + <artifactId>maven-surefire-plugin</artifactId> | |
| 91 | + <version>${maven-surefire-plugin.version}</version> | |
| 92 | + </plugin> | |
| 93 | + </plugins> | |
| 94 | + </build> | |
| 18 | 95 | </project> |
| 1 | +package victor.training.commons; | |
| 2 | + | |
| 3 | +/** What a national number (no {@code +} or {@code 00}) is dialled from: only the calling app knows. */ | |
| 4 | +public interface HomeCountry { | |
| 5 | + String homeDialCode(); | |
| 6 | +} |
| 1 | +package victor.training.commons; | |
| 2 | + | |
| 3 | +import io.opentelemetry.api.GlobalOpenTelemetry; | |
| 4 | +import io.opentelemetry.api.common.AttributeKey; | |
| 5 | +import io.opentelemetry.api.trace.Span; | |
| 6 | +import io.opentelemetry.api.trace.SpanBuilder; | |
| 7 | +import io.opentelemetry.api.trace.StatusCode; | |
| 8 | +import io.opentelemetry.context.Scope; | |
| 9 | +import java.util.Arrays; | |
| 10 | +import java.util.stream.Collectors; | |
| 11 | +import org.aspectj.lang.ProceedingJoinPoint; | |
| 12 | +import org.aspectj.lang.reflect.CodeSignature; | |
| 13 | +import org.aspectj.lang.annotation.Around; | |
| 14 | +import org.aspectj.lang.annotation.Aspect; | |
| 15 | + | |
| 16 | +/** | |
| 17 | + * A span for every call that crosses this jar's boundary, in either direction: an app calling | |
| 18 | + * into commons, and commons calling back out into app code it was handed. A call that stays on | |
| 19 | + * one side gets none. | |
| 20 | + * | |
| 21 | + * <p>Woven into this jar at build time, so the apps change nothing. Without the OpenTelemetry | |
| 22 | + * agent, {@link GlobalOpenTelemetry} is a no-op and so is every span here. | |
| 23 | + */ | |
| 24 | +@Aspect | |
| 25 | +public class ModuleBoundaryTracing { | |
| 26 | + /** The lifeline petclinic-test's trace-to-puml.ts draws a span on, and what kind of thing it is. */ | |
| 27 | + private static final AttributeKey<String> PARTICIPANT = AttributeKey.stringKey("genseq.participant"); | |
| 28 | + private static final AttributeKey<String> STEREOTYPE = AttributeKey.stringKey("genseq.stereotype"); | |
| 29 | + /** Where the method is, as the agent stamps its own spans: the diagram links the arrow to it. */ | |
| 30 | + private static final AttributeKey<String> NAMESPACE = AttributeKey.stringKey("code.namespace"); | |
| 31 | + private static final AttributeKey<String> FUNCTION = AttributeKey.stringKey("code.function"); | |
| 32 | + /** Which overload ran — `String, HomeCountry` — so the link opens that one, not the first by name. */ | |
| 33 | + private static final AttributeKey<String> PARAMETERS = AttributeKey.stringKey("boundary.parameters"); | |
| 34 | + private static final String APPS = "victor.training."; | |
| 35 | + private static final String COMMONS = "victor.training.commons."; | |
| 36 | + | |
| 37 | + // execution() is woven into the callee, which cannot see its caller. This flag is how a | |
| 38 | + // commons method tells a call from an app (a crossing) from a call by commons itself. | |
| 39 | + private static final ThreadLocal<Boolean> INSIDE = ThreadLocal.withInitial(() -> false); | |
| 40 | + | |
| 41 | + // A record is the data both sides exchange: reading one is not a call into the module. | |
| 42 | + @Around("execution(* victor.training.commons..*(..)) && !within(java.lang.Record+)" | |
| 43 | + + " && !within(victor.training.commons.ModuleBoundaryTracing)") | |
| 44 | + public Object enter(ProceedingJoinPoint call) throws Throwable { | |
| 45 | + if (INSIDE.get()) { | |
| 46 | + return call.proceed(); | |
| 47 | + } | |
| 48 | + return traced(call, call.getSignature().getDeclaringType(), true); | |
| 49 | + } | |
| 50 | + | |
| 51 | + @Around("call(* *(..)) && within(victor.training.commons..*)" | |
| 52 | + + " && !within(victor.training.commons.ModuleBoundaryTracing)") | |
| 53 | + public Object leave(ProceedingJoinPoint call) throws Throwable { | |
| 54 | + Object callee = call.getTarget(); | |
| 55 | + if (callee == null || !isAppCode(callee.getClass())) { | |
| 56 | + return call.proceed(); // the JDK, or commons itself | |
| 57 | + } | |
| 58 | + // a lambda's class is hidden and named like Foo$$Lambda/0x…: the interface reads better | |
| 59 | + Class<?> type = callee.getClass().isHidden() ? call.getSignature().getDeclaringType() : callee.getClass(); | |
| 60 | + return traced(call, type, false); | |
| 61 | + } | |
| 62 | + | |
| 63 | + private static boolean isAppCode(Class<?> type) { | |
| 64 | + return type.getName().startsWith(APPS) && !type.getName().startsWith(COMMONS); | |
| 65 | + } | |
| 66 | + | |
| 67 | + private static String parameterTypes(ProceedingJoinPoint call) { | |
| 68 | + return Arrays.stream(((CodeSignature) call.getSignature()).getParameterTypes()) | |
| 69 | + .map(Class::getSimpleName) | |
| 70 | + .collect(Collectors.joining(", ")); | |
| 71 | + } | |
| 72 | + | |
| 73 | + private static Object traced(ProceedingJoinPoint call, Class<?> type, boolean entering) throws Throwable { | |
| 74 | + String method = call.getSignature().getName(); | |
| 75 | + SpanBuilder builder = GlobalOpenTelemetry.getTracer("petclinic-commons") | |
| 76 | + .spanBuilder(type.getSimpleName() + "." + method) | |
| 77 | + .setAttribute(NAMESPACE, type.getName()) | |
| 78 | + .setAttribute(FUNCTION, method) | |
| 79 | + .setAttribute(PARAMETERS, parameterTypes(call)) | |
| 80 | + .setAttribute("boundary.args", Arrays.toString(call.getArgs())); | |
| 81 | + if (entering) { // leaving: drawn on the caller's own lifeline | |
| 82 | + builder.setAttribute(PARTICIPANT, "Commons").setAttribute(STEREOTYPE, "module"); | |
| 83 | + } | |
| 84 | + Span span = builder.startSpan(); | |
| 85 | + boolean wasInside = INSIDE.get(); | |
| 86 | + INSIDE.set(entering); | |
| 87 | + try (Scope ignored = span.makeCurrent()) { | |
| 88 | + Object result = call.proceed(); | |
| 89 | + if (result != null) { | |
| 90 | + span.setAttribute("boundary.return", result.toString()); | |
| 91 | + } | |
| 92 | + return result; | |
| 93 | + } catch (Throwable e) { | |
| 94 | + span.recordException(e); | |
| 95 | + span.setStatus(StatusCode.ERROR); | |
| 96 | + throw e; | |
| 97 | + } finally { | |
| 98 | + INSIDE.set(wasInside); | |
| 99 | + span.end(); | |
| 100 | + } | |
| 101 | + } | |
| 102 | +} |
| 1 | +package victor.training.commons; | |
| 2 | + | |
| 3 | +public final class PhoneNumbers { | |
| 4 | + private PhoneNumbers() { | |
| 5 | + } | |
| 6 | + | |
| 7 | + /** {@code +1 (608) 555-1023} → {@code +16085551023}; a {@code 00} international prefix becomes {@code +}. */ | |
| 8 | + public static String normalize(String phone) { | |
| 9 | + String digits = stripFormatting(phone); | |
| 10 | + return digits.startsWith("00") ? "+" + digits.substring(2) : digits; | |
| 11 | + } | |
| 12 | + | |
| 13 | + /** Like {@link #normalize(String)}, but a national number is given the caller's dial code. */ | |
| 14 | + public static String normalize(String phone, HomeCountry homeCountry) { | |
| 15 | + String normalized = normalize(phone); | |
| 16 | + if (normalized.startsWith("+")) { | |
| 17 | + return normalized; | |
| 18 | + } | |
| 19 | + String withoutTrunkPrefix = normalized.startsWith("0") ? normalized.substring(1) : normalized; | |
| 20 | + return homeCountry.homeDialCode() + withoutTrunkPrefix; | |
| 21 | + } | |
| 22 | + | |
| 23 | + private static String stripFormatting(String phone) { | |
| 24 | + return phone.replaceAll("[^+\\d]", ""); | |
| 25 | + } | |
| 26 | +} |
| 1 | +package victor.training.commons; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import org.junit.jupiter.params.ParameterizedTest; | |
| 6 | +import org.junit.jupiter.params.provider.CsvSource; | |
| 7 | + | |
| 8 | +class PhoneNumbersTest { | |
| 9 | + private static final HomeCountry US = () -> "+1"; | |
| 10 | + | |
| 11 | + @ParameterizedTest | |
| 12 | + @CsvSource(delimiter = '|', value = { | |
| 13 | + "+1 (608) 555-1023 | +16085551023", | |
| 14 | + "(0043)152634418 | +43152634418", | |
| 15 | + "6085551023 | 6085551023"}) | |
| 16 | + void keepsOnlyTheDigitsAndAnInternationalPlus(String typed, String normalized) { | |
| 17 | + assertThat(PhoneNumbers.normalize(typed)).isEqualTo(normalized); | |
| 18 | + } | |
| 19 | + | |
| 20 | + @ParameterizedTest | |
| 21 | + @CsvSource(delimiter = '|', value = { | |
| 22 | + "8474461990 | +18474461990", | |
| 23 | + "0608 555-1023 | +16085551023", | |
| 24 | + "+43152634418 | +43152634418"}) | |
| 25 | + void givesANationalNumberTheHomeDialCode(String typed, String international) { | |
| 26 | + assertThat(PhoneNumbers.normalize(typed, US)).isEqualTo(international); | |
| 27 | + } | |
| 28 | +} |
| 1 | +package victor.training.outside; | |
| 2 | + | |
| 3 | +import static org.assertj.core.api.Assertions.assertThat; | |
| 4 | + | |
| 5 | +import io.opentelemetry.api.common.AttributeKey; | |
| 6 | +import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension; | |
| 7 | +import io.opentelemetry.sdk.trace.data.SpanData; | |
| 8 | +import java.time.LocalDate; | |
| 9 | +import org.junit.jupiter.api.Test; | |
| 10 | +import org.junit.jupiter.api.extension.RegisterExtension; | |
| 11 | +import victor.training.commons.HomeCountry; | |
| 12 | +import victor.training.commons.PhoneNumbers; | |
| 13 | +import victor.training.commons.VisitBookedNotification; | |
| 14 | + | |
| 15 | +/** Calls commons from outside its package, as the apps do: each crossing is a span, nothing else is. */ | |
| 16 | +class ModuleBoundaryTracingTest { | |
| 17 | + private static final AttributeKey<String> PARTICIPANT = AttributeKey.stringKey("genseq.participant"); | |
| 18 | + private static final AttributeKey<String> STEREOTYPE = AttributeKey.stringKey("genseq.stereotype"); | |
| 19 | + private static final AttributeKey<String> NAMESPACE = AttributeKey.stringKey("code.namespace"); | |
| 20 | + private static final AttributeKey<String> FUNCTION = AttributeKey.stringKey("code.function"); | |
| 21 | + private static final AttributeKey<String> ARGS = AttributeKey.stringKey("boundary.args"); | |
| 22 | + private static final AttributeKey<String> RETURN = AttributeKey.stringKey("boundary.return"); | |
| 23 | + private static final AttributeKey<String> PARAMETERS = AttributeKey.stringKey("boundary.parameters"); | |
| 24 | + | |
| 25 | + @RegisterExtension | |
| 26 | + static final OpenTelemetryExtension otel = OpenTelemetryExtension.create(); | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + void aCallIntoCommonsIsASpanOnTheModuleLifelineButTheCallsInsideItAreNot() { | |
| 30 | + PhoneNumbers.normalize("+1 (608) 555-1023"); | |
| 31 | + | |
| 32 | + assertThat(otel.getSpans()).singleElement().satisfies(span -> { | |
| 33 | + assertThat(span.getName()).isEqualTo("PhoneNumbers.normalize"); | |
| 34 | + assertThat(span.getAttributes().get(PARTICIPANT)).isEqualTo("Commons"); | |
| 35 | + assertThat(span.getAttributes().get(STEREOTYPE)).isEqualTo("module"); | |
| 36 | + assertThat(span.getAttributes().get(NAMESPACE)).isEqualTo(PhoneNumbers.class.getName()); | |
| 37 | + assertThat(span.getAttributes().get(FUNCTION)).isEqualTo("normalize"); | |
| 38 | + assertThat(span.getAttributes().get(PARAMETERS)).isEqualTo("String"); | |
| 39 | + assertThat(span.getAttributes().get(ARGS)).isEqualTo("[+1 (608) 555-1023]"); | |
| 40 | + assertThat(span.getAttributes().get(RETURN)).isEqualTo("+16085551023"); | |
| 41 | + }); | |
| 42 | + } | |
| 43 | + | |
| 44 | + @Test | |
| 45 | + void aCallFromCommonsBackOutIsASpanNestedInTheOneThatEntered() { | |
| 46 | + PhoneNumbers.normalize("8474461990", new Clinic()); | |
| 47 | + | |
| 48 | + SpanData entered = span("PhoneNumbers.normalize"); | |
| 49 | + SpanData left = span("Clinic.homeDialCode"); | |
| 50 | + assertThat(entered.getAttributes().get(PARAMETERS)).as("which overload ran").isEqualTo("String, HomeCountry"); | |
| 51 | + assertThat(left.getParentSpanId()).isEqualTo(entered.getSpanId()); | |
| 52 | + assertThat(left.getAttributes().get(PARTICIPANT)).as("drawn on its caller's own lifeline").isNull(); | |
| 53 | + assertThat(left.getAttributes().get(NAMESPACE)).isEqualTo(Clinic.class.getName()); | |
| 54 | + assertThat(left.getAttributes().get(FUNCTION)).isEqualTo("homeDialCode"); | |
| 55 | + } | |
| 56 | + | |
| 57 | + @Test | |
| 58 | + void reEnteringCommonsFromAppCodeIsACrossingAgain() { | |
| 59 | + HomeCountry asksCommonsBack = () -> PhoneNumbers.normalize("+1"); | |
| 60 | + PhoneNumbers.normalize("8474461990", asksCommonsBack); | |
| 61 | + | |
| 62 | + assertThat(otel.getSpans()).extracting(SpanData::getName) | |
| 63 | + .containsExactlyInAnyOrder( | |
| 64 | + "PhoneNumbers.normalize", "HomeCountry.homeDialCode", "PhoneNumbers.normalize"); | |
| 65 | + } | |
| 66 | + | |
| 67 | + @Test | |
| 68 | + void readingASharedRecordIsNotACallIntoTheModule() { | |
| 69 | + new VisitBookedNotification("+16085551023", "Leo", LocalDate.of(2026, 10, 1)).ownerPhone(); | |
| 70 | + | |
| 71 | + assertThat(otel.getSpans()).isEmpty(); | |
| 72 | + } | |
| 73 | + | |
| 74 | + private static SpanData span(String name) { | |
| 75 | + return otel.getSpans().stream().filter(s -> s.getName().equals(name)).findFirst().orElseThrow(); | |
| 76 | + } | |
| 77 | + | |
| 78 | + static class Clinic implements HomeCountry { | |
| 79 | + @Override | |
| 80 | + public String homeDialCode() { | |
| 81 | + return "+1"; | |
| 82 | + } | |
| 83 | + } | |
| 84 | +} |
| 1 | +@startuml | |
| 2 | + | |
| 3 | +title Frontend Logical Architecture | |
| 4 | + | |
| 5 | +skinparam shadowing false | |
| 6 | +' the review page themes this colour for dark mode (--dgm-link) | |
| 7 | +skinparam hyperlinkColor #1A4FA0 | |
| 8 | +skinparam componentStyle rectangle | |
| 9 | +skinparam nodesep 20 | |
| 10 | +' the stereotype is the box's folder, relative to petclinic-frontend/, which the test and the | |
| 11 | +' review page read; it is hidden so the folder is not printed under its name. | |
| 12 | +' A file belongs to the box with the deepest folder containing it. | |
| 13 | +' Only production .ts counts (*.spec.ts is skipped, like ArchUnit's DoNotIncludeTests). | |
| 14 | +' testing/ keeps its box: its stubs are non-spec files, imported by specs alone. | |
| 15 | +' Imports leaving src/app (../environments) are not boxes and are not checked. | |
| 16 | +hide stereotype | |
| 17 | +skinparam ranksep 30 | |
| 18 | +<style> | |
| 19 | +component { | |
| 20 | + BackGroundColor #ECEFF1 | |
| 21 | + LineColor #546E7A | |
| 22 | +} | |
| 23 | +arrow { | |
| 24 | + LineColor #78909C | |
| 25 | +} | |
| 26 | +</style> | |
| 27 | + | |
| 28 | +[app] <<src/app>> | |
| 29 | +[owners] <<src/app/owners>> | |
| 30 | +[vets] <<src/app/vets>> | |
| 31 | +[pets] <<src/app/pets>> | |
| 32 | +[visits] <<src/app/visits>> | |
| 33 | +[pettypes] <<src/app/pettypes>> | |
| 34 | +[specialties] <<src/app/specialties>> | |
| 35 | +[parts] <<src/app/parts>> | |
| 36 | +[invoice] <<src/app/invoice>> | |
| 37 | +[design-system] <<src/app/design-system>> | |
| 38 | +[generated] <<src/app/generated>> | |
| 39 | +[testing] <<src/app/testing>> | |
| 40 | + | |
| 41 | +' <--> is two arrows, one each way: the test reads it as both dependencies. | |
| 42 | +' Every feature module is registered by app.module, and every feature service | |
| 43 | +' takes HttpErrorHandler from the root error.service. | |
| 44 | +[app] <--> [owners] | |
| 45 | +[app] <--> [pets] | |
| 46 | +[app] <--> [visits] | |
| 47 | +[app] <--> [vets] | |
| 48 | +[app] <--> [pettypes] | |
| 49 | +[app] <--> [specialties] | |
| 50 | +[app] --> [parts] | |
| 51 | + | |
| 52 | +[owners] <--> [pets] | |
| 53 | +[pets] <--> [visits] | |
| 54 | +[visits] --> [owners] | |
| 55 | +[pets] --> [pettypes] | |
| 56 | +[visits] --> [pettypes] | |
| 57 | +[visits] --> [vets] | |
| 58 | +[pettypes] --> [specialties] | |
| 59 | +[vets] --> [specialties] | |
| 60 | + | |
| 61 | +[pets] --> [design-system] | |
| 62 | +[vets] --> [design-system] | |
| 63 | +[visits] --> [design-system] | |
| 64 | + | |
| 65 | +[owners] --> [generated] | |
| 66 | +[pets] --> [generated] | |
| 67 | +[visits] --> [generated] | |
| 68 | +[pettypes] --> [generated] | |
| 69 | +[specialties] --> [generated] | |
| 70 | +[vets] --> [generated] | |
| 71 | + | |
| 72 | +' layout only, ignored by the test: shared and unused boxes on the bottom row | |
| 73 | +[specialties] -[hidden]-> [parts] | |
| 74 | +[specialties] -[hidden]-> [invoice] | |
| 75 | +[specialties] -[hidden]-> [testing] | |
| 76 | + | |
| 77 | +@enduml |
| 13 | 13 | "test": "ng test", |
| 14 | 14 | "test-headless": "ng test --no-watch --no-progress --browsers=ChromeHeadlessCI", |
| 15 | 15 | "lint": "ng lint", |
| 16 | + "test:arch": "node --test scripts/packages-arch.test.mjs", | |
| 16 | 17 | "e2e": "ng e2e" |
| 17 | 18 | }, |
| 18 | 19 | "private": true, |
| 1 | +// The frontend twin of the backend's PackagesArchTest: docs/frontend-packages.puml is the | |
| 2 | +// allowed dependency picture, and the code may not import across boxes the picture does not | |
| 3 | +// connect. An arrow nobody uses does not fail, as in ArchUnit. Zero dependencies on purpose. | |
| 4 | +import {test} from 'node:test'; | |
| 5 | +import assert from 'node:assert/strict'; | |
| 6 | +import {existsSync, readFileSync, readdirSync, statSync} from 'node:fs'; | |
| 7 | +import {dirname, join, relative, resolve, sep} from 'node:path'; | |
| 8 | +import {fileURLToPath} from 'node:url'; | |
| 9 | + | |
| 10 | +const FRONTEND = resolve(dirname(fileURLToPath(import.meta.url)), '..'); | |
| 11 | +const DIAGRAM = 'docs/frontend-packages.puml'; | |
| 12 | +const APP = 'src/app'; | |
| 13 | + | |
| 14 | +const toPosix = p => p.split(sep).join('/'); | |
| 15 | +const rel = abs => toPosix(relative(FRONTEND, abs)); | |
| 16 | +const isProduction = file => file.endsWith('.ts') && !file.endsWith('.spec.ts') && !file.endsWith('.d.ts'); | |
| 17 | + | |
| 18 | +function parseDiagram() { | |
| 19 | + const boxes = new Map(); // label -> folder relative to petclinic-frontend/ | |
| 20 | + const arrows = new Set(); // "from->to" | |
| 21 | + for (const raw of readFileSync(join(FRONTEND, DIAGRAM), 'utf8').split('\n')) { | |
| 22 | + const line = raw.trim(); | |
| 23 | + if (line.startsWith("'")) continue; | |
| 24 | + const box = line.match(/^\[([^\]]+)\]\s*(?:as\s+\S+\s*)?<<([^>]+)>>/); | |
| 25 | + if (box) boxes.set(box[1], box[2].trim().replace(/\/$/, '')); | |
| 26 | + // [a] --> [b], [a] -up-> [b], [a] <--> [b] (both ways); [a] -[hidden]-> [b] is layout only | |
| 27 | + const arrow = line.match(/^\[([^\]]+)\]\s*(<?[-.]\S*?[-.]>?)\s*\[([^\]]+)\]/); | |
| 28 | + if (arrow && !arrow[2].includes('hidden')) { | |
| 29 | + const [, from, shaft, to] = arrow; | |
| 30 | + if (shaft.endsWith('>')) arrows.add(`${from}->${to}`); | |
| 31 | + if (shaft.startsWith('<')) arrows.add(`${to}->${from}`); | |
| 32 | + } | |
| 33 | + } | |
| 34 | + return {boxes, arrows}; | |
| 35 | +} | |
| 36 | + | |
| 37 | +function walk(dir) { | |
| 38 | + return readdirSync(dir).flatMap(name => { | |
| 39 | + const path = join(dir, name); | |
| 40 | + return statSync(path).isDirectory() ? walk(path) : [path]; | |
| 41 | + }); | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** The box whose folder is the deepest one containing the file, or undefined. */ | |
| 45 | +function boxOf(file, boxes) { | |
| 46 | + let best; | |
| 47 | + for (const [label, folder] of boxes) { | |
| 48 | + if ((file === folder || file.startsWith(folder + '/')) && (!best || folder.length > best.folder.length)) { | |
| 49 | + best = {label, folder}; | |
| 50 | + } | |
| 51 | + } | |
| 52 | + return best?.label; | |
| 53 | +} | |
| 54 | + | |
| 55 | +/** Resolves a relative specifier as TypeScript would: file.ts, then folder/index.ts. */ | |
| 56 | +function resolveImport(fromFile, specifier) { | |
| 57 | + const base = resolve(FRONTEND, dirname(fromFile), specifier); | |
| 58 | + for (const candidate of [base, `${base}.ts`, join(base, 'index.ts')]) { | |
| 59 | + if (existsSync(candidate) && statSync(candidate).isFile()) return rel(candidate); | |
| 60 | + } | |
| 61 | + if (existsSync(base) && statSync(base).isDirectory()) return rel(base); // a folder with no barrel | |
| 62 | + return undefined; | |
| 63 | +} | |
| 64 | + | |
| 65 | +function relativeImports(source) { | |
| 66 | + const found = []; | |
| 67 | + const pattern = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(\.{1,2}\/[^'"]*)\1/g; | |
| 68 | + for (const match of source.matchAll(pattern)) { | |
| 69 | + found.push({specifier: match[2], line: source.slice(0, match.index).split('\n').length}); | |
| 70 | + } | |
| 71 | + return found; | |
| 72 | +} | |
| 73 | + | |
| 74 | +const {boxes, arrows} = parseDiagram(); | |
| 75 | +const files = walk(join(FRONTEND, APP)).map(rel).filter(isProduction); | |
| 76 | + | |
| 77 | +test(`every cross-box import is an arrow in ${DIAGRAM}`, () => { | |
| 78 | + const violations = []; | |
| 79 | + for (const file of files) { | |
| 80 | + const fromBox = boxOf(file, boxes); | |
| 81 | + for (const {specifier, line} of relativeImports(readFileSync(join(FRONTEND, file), 'utf8'))) { | |
| 82 | + const target = resolveImport(file, specifier); | |
| 83 | + if (!target) { | |
| 84 | + violations.push(`${file}:${line} imports '${specifier}', which resolves to no file`); | |
| 85 | + continue; | |
| 86 | + } | |
| 87 | + if (!target.startsWith(APP + '/')) continue; // ../environments etc. are outside the picture | |
| 88 | + const toBox = boxOf(target, boxes); | |
| 89 | + if (fromBox && toBox && fromBox !== toBox && !arrows.has(`${fromBox}->${toBox}`)) { | |
| 90 | + violations.push(`${file}:${line} → ${target} needs the arrow [${fromBox}] --> [${toBox}]`); | |
| 91 | + } | |
| 92 | + } | |
| 93 | + } | |
| 94 | + assert.ok(violations.length === 0, `\n${violations.length} import(s) not drawn in ${DIAGRAM}:\n ` | |
| 95 | + + violations.join('\n ') + '\nDraw the arrow if the dependency is intended, or remove the import.\n'); | |
| 96 | +}); | |
| 97 | + | |
| 98 | +test(`every production .ts file under ${APP} is in a box`, () => { | |
| 99 | + const orphans = files.filter(file => !boxOf(file, boxes)); | |
| 100 | + assert.ok(orphans.length === 0, `\nFiles in no box of ${DIAGRAM}:\n ${orphans.join('\n ')}\n`); | |
| 101 | +}); | |
| 102 | + | |
| 103 | +test(`the boxes of ${DIAGRAM} are exactly the folders of ${APP}`, () => { | |
| 104 | + const problems = []; | |
| 105 | + for (const [label, folder] of boxes) { | |
| 106 | + if (!files.some(file => file.startsWith(folder + '/'))) { | |
| 107 | + problems.push(`box [${label}] <<${folder}>>: no production .ts file in that folder`); | |
| 108 | + } | |
| 109 | + } | |
| 110 | + const boxed = new Set(boxes.values()); | |
| 111 | + const topFolders = new Set(files.map(file => file.slice(APP.length + 1).split('/')) | |
| 112 | + .filter(parts => parts.length > 1).map(parts => `${APP}/${parts[0]}`)); | |
| 113 | + for (const folder of [APP, ...topFolders]) { | |
| 114 | + if (!boxed.has(folder)) { | |
| 115 | + problems.push(`folder ${folder} has no box: add [${folder.split('/').pop()}] <<${folder}>>`); | |
| 116 | + } | |
| 117 | + } | |
| 118 | + assert.ok(problems.length === 0, `\nThe diagram and ${APP} disagree:\n ${problems.join('\n ')}\n`); | |
| 119 | +}); |
| 51 | 51 | <div class="form-group"> |
| 52 | 52 | <label for="vet" class="col-sm-2 control-label">Vet</label> |
| 53 | 53 | <div class="col-sm-10"> |
| 54 | - <app-combo inputId="vet" name="vetId" [(ngModel)]="visit.vetId" [options]="vetOptions" valueKey="id" | |
| 55 | - [placeholder]="noVet"></app-combo> | |
| 54 | + <!-- | |
| 55 | + Planted on purpose for the human-review demo: a native <select> where the design | |
| 56 | + system's <app-combo> belongs, so the UX audit has a gap to show. Book a Visit keeps | |
| 57 | + the combo. Do not "fix" this. | |
| 58 | + --> | |
| 59 | + <select id="vet" name="vetId" class="form-control" [(ngModel)]="visit.vetId"> | |
| 60 | + <option [ngValue]="null">-- none --</option> | |
| 61 | + <option *ngFor="let vetOption of vetOptions" [ngValue]="vetOption.id">{{ vetOption.name }}</option> | |
| 62 | + </select> | |
| 56 | 63 | </div> |
| 57 | 64 | </div> |
| 58 | 65 | </div> |
| 1 | +# Tempo on its own, no Docker, no Grafana — started by ../start-tempo.sh. | |
| 2 | +# It is all the sequence diagrams need: OTLP in on :4318, the search/trace API on :3200 | |
| 3 | +# (petclinic-test/src/genseq/tempo-client.ts talks to it directly when TEMPO_URL is set). | |
| 4 | +# Traces live under ./.tempo-data and are wiped on every start. | |
| 5 | +stream_over_http_enabled: true | |
| 6 | + | |
| 7 | +server: | |
| 8 | + http_listen_address: 127.0.0.1 | |
| 9 | + http_listen_port: 3200 | |
| 10 | + grpc_listen_address: 127.0.0.1 | |
| 11 | + grpc_listen_port: 9095 | |
| 12 | + log_level: error | |
| 13 | + | |
| 14 | +distributor: | |
| 15 | + receivers: | |
| 16 | + otlp: | |
| 17 | + protocols: | |
| 18 | + http: | |
| 19 | + endpoint: "127.0.0.1:4318" | |
| 20 | + | |
| 21 | +# Tempo 3 leaves the last 30 s out of every search, so as not to answer with a trace still | |
| 22 | +# coming in. The diagrams are fetched seconds after a test, and the generator waits for each | |
| 23 | +# test's window to close anyway; at 30 s it gave up on every one of them. | |
| 24 | +query_frontend: | |
| 25 | + query_end_cutoff: 0s | |
| 26 | + | |
| 27 | +storage: | |
| 28 | + trace: | |
| 29 | + backend: local | |
| 30 | + wal: | |
| 31 | + path: ./.tempo-data/wal | |
| 32 | + local: | |
| 33 | + path: ./.tempo-data/blocks | |
| 34 | + | |
| 35 | +# Tempo 3 defaults every one of these to /var/tempo, which a non-root user cannot create. | |
| 36 | +live_store: | |
| 37 | + # A trace is searchable once the live store has cut it: 5-10 s after its last span by | |
| 38 | + # default, which already eats most of the generator's 14 s of retries. | |
| 39 | + max_trace_idle: 1s | |
| 40 | + flush_check_period: 1s | |
| 41 | + shutdown_marker_dir: ./.tempo-data/live-store/shutdown-marker | |
| 42 | + wal: | |
| 43 | + path: ./.tempo-data/live-store/wal | |
| 44 | +block_builder: | |
| 45 | + wal: | |
| 46 | + path: ./.tempo-data/block-builder/wal | |
| 47 | +backend_scheduler: | |
| 48 | + local_work_path: ./.tempo-data/backend-scheduler | |
| 49 | + | |
| 50 | +usage_report: | |
| 51 | + reporting_enabled: false |
| 33 | 33 | `OTEL_EXPORTER_OTLP_ENDPOINT`, `BACKEND_URL`, keeping the dev port as the default. In a |
| 34 | 34 | container the browser's spans reach the collector only through the frontend's nginx |
| 35 | 35 | (`location = /v1/traces` → `lgtm:4318`), the same path `proxy.conf.js` serves under `ng serve`. |
| 36 | -- ⚠️ **Specs in `src/` must not create/delete visits or owners.** `visits.spec.ts` compares the | |
| 37 | - *entire* visit list against the API, so a row appearing mid-run fails an unrelated test — | |
| 38 | - the suite runs `fullyParallel` against one shared DB. | |
| 36 | +- **Every test starts on the DB fixture it declares, and declaring nothing means empty** — the | |
| 37 | + schema plus the reference rows (pet types, specialties). A spec that reads rows it did not | |
| 38 | + write says so: `test.use({dbFixture: 'default'})` for the seed, or the name of a | |
| 39 | + `db/fixtures/<name>.sql`; a Cucumber feature or scenario tags itself `@fixture:default`. | |
| 40 | + The reset runs before **every** test (`src/support/db-fixture.ts`, through the docker | |
| 41 | + stack's `/__reset` sidecar), which is why the suite runs on **one worker**: a reset empties | |
| 42 | + the one database the stack shares. On `ng serve` there is no sidecar, and the tests run on | |
| 43 | + whatever the database holds, with a warning. The review page draws the declared fixture as | |
| 44 | + the test's dot; a test with no dot started empty. | |
| 39 | 45 | - A `Backend -> DB` arrow is labelled with **the call the query came from**, not the query — |
| 40 | 46 | `SELECT petclinic` (operation + *database*) is what all sixty queries of an N+1 are named. |
| 41 | 47 | `src/genseq/trace-to-puml.ts` takes the first of: Hibernate's own comment on the statement |
| 104 | 110 | method) the commit lands on the SERVER span, so the frame wraps the handler's *body*. |
| 105 | 111 | Framing the span itself would swallow the request and response arrows with it, and the |
| 106 | 112 | picture would lose the call it is about. |
| 113 | +- **How a transaction ended is written beside its tab**: `group tx [committed]` in grey, | |
| 114 | + `[rolled back]` / `[commit failed]` in amber (PlantUML draws a group's `[…]` tail to the | |
| 115 | + right of the tab). The agent records no rollback at all, so the backend's | |
| 116 | + `TransactionOutcomeTracing` (a Spring `TransactionExecutionListener`) stamps | |
| 117 | + `transaction.outcome` on the span that opened the transaction; a trace without it falls back | |
| 118 | + to the commit span, i.e. `committed`. | |
| 107 | 119 | - The **`Session.*` / `Hibernate Query` spans are dropped** when a Spring Data repository span |
| 108 | 120 | above them already said the same thing. They are kept with no repository above them — code |
| 109 | 121 | using the EntityManager directly, where the session call is the only account of the request. |
| 130 | 142 | `1 / 2` — advertising neither that it existed nor what it would do. `SEQ_INTERACTIVE=0` |
| 131 | 143 | bakes the detail back into the picture, which is what `diagram:lean`/`:static`/`:full` do. The ids are **hashes of the detail, never counters** — `.claude/skills/human-review/scripts/puml-diff.sh` diffs the |
| 132 | 144 | `.puml` textually, so a positional id would repaint everything under an inserted query. |
| 145 | +- **A `Browser -> Backend` arrow links to the line that sent that request**, taken from the | |
| 146 | + request's own JS stack, never from a search (README, "A browser request links…"). It needs no | |
| 147 | + application change and no extra build: `src/support/call-sites.ts` wraps XHR and fetch from | |
| 148 | + outside, and the source maps of the `docker` build are served beside the bundles even though | |
| 149 | + they are `hidden`. Both fixtures record it for traced tests only. An arrow without a link is | |
| 150 | + the honest answer, so do not add a fallback: `npm run trace:diagram` logs | |
| 151 | + `n/m browser request(s) traced to their line` per test. | |
| 133 | 152 | - The windows file (`test-results/trace-windows.json`) is what a standalone re-render replays, |
| 134 | 153 | so each runner forgets only **its own** entries at start (`*.spec.ts` for Playwright, |
| 135 | 154 | `*.feature` for Cucumber) — wiping it whole would shrink re-renders to the last suite that ran. |
| They are generated artifacts and say so on their first lines; edit the test, not | ||
| 72 | 72 | backend only attaches the OpenTelemetry Java agent if `:4318` is already listening. Both |
| 73 | 73 | scripts now say so out loud if the order was wrong, instead of silently producing no diagrams. |
| 74 | 74 | |
| 75 | +**No Docker?** `../start-tempo.sh` in place of `./start-grafana.sh`: Tempo alone, one binary | |
| 76 | +downloaded on first start, OTLP on the same `:4318` and its API on `:3200`. The diagrams need | |
| 77 | +nothing more — `run-tests-with-tracing.sh` (both) find it by themselves when no Grafana answers | |
| 78 | +on `:3300`, and `generate.ts` reads it from `TEMPO_URL`. Same start order: Tempo before backend. | |
| 79 | + | |
| 75 | 80 | **Or skip the host stack entirely** — an isolated instance carries its own Tempo, on ports the |
| 76 | 81 | host picks, from a freshly seeded database (what `/human-review` does, `human-review.json` |
| 77 | 82 | `steps.sequence.app`): |
| the SVG. Clicking that arrow in `review/review.html` opens a panel beside it — | ||
| 130 | 135 | |
| 131 | 136 | State is per arrow, not a switch on the page: one arrow open says nothing about the next. |
| 132 | 137 | |
| 138 | +**A browser request links to the line that sent it, in that run.** The same endpoint is | |
| 139 | +called from several places (`GET /api/owners/{ownerId}` comes from `owner-detail.component.ts:22` | |
| 140 | +and from `visit-add.component.ts:47` in one test), so no scan of the source can say which; the | |
| 141 | +request itself does. No application code changes for it: | |
| 142 | + | |
| 143 | +1. `src/support/call-sites.ts` wraps `XMLHttpRequest` and `fetch` from `page.addInitScript`, | |
| 144 | + before the app loads, so zone.js and OTel wrap on top of it: by the time a request reaches | |
| 145 | + it OTel has set its `traceparent`, and `new Error().stack` still has the whole way down. | |
| 146 | +2. At the end of the test each stack goes through the source maps served beside the bundles | |
| 147 | + (`hidden` only drops the `sourceMappingURL` comment; nginx still serves `main.<hash>.js.map`) | |
| 148 | + into `test-results/call-sites/`. `Error.stackTraceLimit` is lifted: the app frame sits | |
| 149 | + about 48 frames down, and the default keeps 10. | |
| 150 | +3. `src/genseq/call-site.ts` joins them on the browser's CLIENT span (`traceparent` is its id) | |
| 151 | + and stamps `genseq.call-site` on it, so `trace-spans.json` replays with the links. | |
| 152 | + | |
| 153 | +The arrow links to the first application frame, normally the component's `.subscribe(`: | |
| 154 | +`http.get` is a cold Observable, so the service method has returned and is not on the stack. A | |
| 155 | +service above a component yields to the component; the other frames go in the tooltip. | |
| 156 | + | |
| 157 | +A request sent after a hop has no application frame at all: `router.navigate` crosses a | |
| 158 | +`Promise.resolve`, so sorting the visits table sends `GET /api/visits` from a stack of pure | |
| 159 | +rxjs. Chrome's async stacks do not see that hop, because zone.js drains its own microtask queue | |
| 160 | +inside the click, so the init script also takes the stack wherever Zone schedules a task. Such | |
| 161 | +a request links to the handler the template called (`onSort`, `onPage`), not to the `navigate()` | |
| 162 | +helper they share. The line is confirmed against the checkout. No stack, no map or a line that moved means no link on | |
| 163 | +that arrow, never a guess. A body keeps its `⊕` as a second link beside the words. | |
| 164 | + | |
| 133 | 165 | Two files come out of a run, and both belong to the diagram: |
| 134 | 166 | |
| 135 | 167 | | | | |
| 32 | 32 | "@types/node": "^25.5.2", |
| 33 | 33 | "axios": "^1.14.0", |
| 34 | 34 | "concurrently": "^9.2.1", |
| 35 | + "source-map": "^0.6.1", | |
| 35 | 36 | "tree-kill": "^1.2.2", |
| 36 | 37 | "ts-node": "^10.9.2", |
| 37 | 38 | "typescript": "^6.0.2", |
| export default defineConfig({ | ||
| 13 | 13 | // to the per-test artifacts, not in a second top-level folder. They must not |
| 14 | 14 | // nest: the HTML reporter wipes its own folder before writing. |
| 15 | 15 | outputDir: './test-results/artifacts', |
| 16 | - fullyParallel: true, | |
| 17 | 16 | forbidOnly: !!process.env.CI, |
| 18 | 17 | retries: process.env.CI ? 2 : 0, |
| 19 | - // One worker under COVERAGE_DIR as well: per-test coverage dumps the backend's one global | |
| 20 | - // set of counters after each test, and two tests running at once would share a dump | |
| 21 | - // (src/support/coverage.ts). | |
| 22 | - workers: process.env.CI || process.env.COVERAGE_DIR ? 1 : undefined, | |
| 18 | + // One test at a time, always. Each test starts on the DB fixture it declares — 'empty' | |
| 19 | + // unless it says `test.use({dbFixture: 'default'})` — and the reset before it | |
| 20 | + // (src/support/trace-fixture.ts) empties the ONE database the whole stack shares: a second | |
| 21 | + // worker's test would lose its rows mid-run. Datasets as projects chained by `dependencies` | |
| 22 | + // would keep parallelism inside a group, but Playwright skips every dependent project as | |
| 23 | + // soon as one test of its dependency fails, so one red spec would hide the rest of the suite. | |
| 24 | + // Per-test coverage needed one worker anyway: it dumps the backend's one global set of | |
| 25 | + // counters after each test (src/support/coverage.ts). | |
| 26 | + fullyParallel: false, | |
| 27 | + workers: 1, | |
| 23 | 28 | reporter: [ |
| 24 | 29 | ['html', {outputFolder: 'test-results/playwright-report'}], |
| 25 | 30 | ['list'], |
| 11 | 11 | # |
| 12 | 12 | # ./start-database.sh # embedded Postgres :5432 |
| 13 | 13 | # ./start-grafana.sh # Grafana LGTM + Tempo :3300, OTLP :4318 |
| 14 | +# or ./start-tempo.sh # Tempo alone, no Docker :3200, OTLP :4318 | |
| 14 | 15 | # ./start-backend.sh # Spring Boot + OTel agent :8080 |
| 15 | 16 | # ./start-frontend.sh # Angular dev server :4200 |
| 16 | 17 | # |
| 22 | 23 | # |
| 23 | 24 | # Every endpoint comes from the environment when set — BASE_URL, BACKEND_URL, |
| 24 | 25 | # GRAFANA_URL, OTEL_EXPORTER_OTLP_ENDPOINT, the names `start-docker.sh ports` |
| 25 | -# prints — and defaults to the dev stack's fixed ports above. | |
| 26 | +# prints, or TEMPO_URL for a bare Tempo — and defaults to the dev stack's fixed | |
| 27 | +# ports above: Grafana if it answers on :3300, else ./start-tempo.sh's :3200. | |
| 26 | 28 | # |
| 27 | 29 | # It does NOT start or stop anything — it only verifies the stack is reachable, |
| 28 | 30 | # runs both suites (which reuse the running apps), and reports the diagrams |
| http_up() { curl -fsS "$1" >/dev/null 2>&1; } | ||
| 54 | 56 | answers() { curl -s -o /dev/null --max-time 3 "$1"; } |
| 55 | 57 | port_up() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&- 3<&-; } |
| 56 | 58 | |
| 59 | +# No Grafana named and none on :3300, but ./start-tempo.sh's Tempo is up: fetch from it | |
| 60 | +# directly. Exported, so the diagram generator the suites run reads it too. | |
| 61 | +if [[ -z "${TEMPO_URL:-}${GRAFANA_URL:-}" ]] && ! http_up "$GRAFANA/api/health" \ | |
| 62 | + && http_up "http://127.0.0.1:3200/ready"; then | |
| 63 | + export TEMPO_URL=http://127.0.0.1:3200 | |
| 64 | +fi | |
| 65 | + | |
| 57 | 66 | # --- preflight: the stack must already be running -------------------------- |
| 58 | 67 | down=() |
| 59 | -http_up "$GRAFANA/api/health" || down+=("Grafana ($GRAFANA) → ./start-grafana.sh") | |
| 60 | -answers "$OTLP/v1/traces" || down+=("OTLP collector ($OTLP) → ./start-grafana.sh") | |
| 68 | +if [[ -n "${TEMPO_URL:-}" ]]; then | |
| 69 | + http_up "$TEMPO_URL/ready" || down+=("Tempo ($TEMPO_URL) → ./start-tempo.sh") | |
| 70 | +else | |
| 71 | + http_up "$GRAFANA/api/health" || down+=("Grafana ($GRAFANA) → ./start-grafana.sh, or ./start-tempo.sh (no Docker)") | |
| 72 | +fi | |
| 73 | +answers "$OTLP/v1/traces" || down+=("OTLP collector ($OTLP) → ./start-grafana.sh or ./start-tempo.sh") | |
| 61 | 74 | ((DEV_STACK)) && { port_up "$DB_PORT" || down+=("Postgres (:$DB_PORT) → ./start-database.sh"); } |
| 62 | 75 | http_up "$BACKEND/api/pettypes" || down+=("Backend ($BACKEND) → ./start-backend.sh") |
| 63 | 76 | http_up "$FRONTEND/" || down+=("Frontend ($FRONTEND) → ./start-frontend.sh") |
| if ((${#down[@]})); then | ||
| 67 | 80 | for d in "${down[@]}"; do printf ' • %s\n' "$d" >&2; done |
| 68 | 81 | die "aborting — nothing was started or stopped." |
| 69 | 82 | fi |
| 70 | -log "✅ Stack reachable (Grafana, Tempo/OTLP, Postgres, backend+agent, frontend)." | |
| 83 | +store="Grafana+Tempo"; [[ -n "${TEMPO_URL:-}" ]] && store="Tempo alone at $TEMPO_URL" | |
| 84 | +log "✅ Stack reachable ($store, OTLP, Postgres, backend+agent, frontend)." | |
| 71 | 85 | |
| 72 | 86 | # The backend only attaches the OTel agent when :4318 was already up at boot, so |
| 73 | 87 | # a backend started before Grafana passes every check above yet emits no traces. |
| rm -f "$UI_TEST_DIR"/generated/*.genseq.puml "$UI_TEST_DIR"/generated/*.genseq.j | ||
| 94 | 108 | # tests a branch wrote (src/genseq/sequence-tag.ts). Cucumber already runs every scenario |
| 95 | 109 | # and reads the list itself; Playwright only runs what its --grep lets through, so the |
| 96 | 110 | # selected spec titles join the tag there. |
| 111 | +# One worker: every spec shares the one traced stack and its database, so in parallel a | |
| 112 | +# test that books a visit shifts the rows another is paging through, and both time out | |
| 113 | +# (visits-paginated, 10 Oct 2026: 2 of 6 red, green on a rerun alone). The coverage run | |
| 114 | +# already goes one at a time (playwright.config.ts, COVERAGE_DIR). | |
| 97 | 115 | if [[ -n "${GENSEQ_SELECT:-}" ]]; then |
| 98 | 116 | log "Also tracing $(grep -c . <<<"$GENSEQ_SELECT") test(s) named in GENSEQ_SELECT." |
| 99 | 117 | log "Running the tagged and the selected Playwright specs…" |
| 100 | - SKIP_SERVER_START=1 npx playwright test --grep "$(npx ts-node src/genseq/sequence-tag.ts grep)" | |
| 118 | + SKIP_SERVER_START=1 npx playwright test --workers=1 --grep "$(npx ts-node src/genseq/sequence-tag.ts grep)" | |
| 101 | 119 | else |
| 102 | 120 | log "Running the tagged Playwright spec…" |
| 103 | - npm run test:sequence | |
| 121 | + npm run test:sequence -- --workers=1 | |
| 104 | 122 | fi |
| 105 | 123 | test_status=$? |
| 106 | 124 | |
| if ((${#diagrams[@]})); then | ||
| 117 | 135 | for d in "${diagrams[@]}"; do echo " - ${d#"$ROOT"/}"; done |
| 118 | 136 | else |
| 119 | 137 | warn "No .puml collected — is the backend running WITH the OTel agent" |
| 120 | - warn "(./start-backend.sh while Grafana is up), so traces reached Tempo?" | |
| 138 | + warn "(./start-backend.sh while Grafana or Tempo is up), so traces reached Tempo?" | |
| 121 | 139 | fi |
| 122 | 140 | |
| 123 | 141 | if [[ $test_status -eq 0 ]]; then |
| 1 | 1 | import {expect, Page} from '@playwright/test'; |
| 2 | 2 | import axios from 'axios'; |
| 3 | 3 | |
| 4 | -// The sentences of add-visit.spec.ts, as plain functions: named for what the | |
| 5 | -// reader of a scenario wants to see, not for the widget being clicked. The | |
| 6 | -// selectors live here so the spec never mentions one. | |
| 4 | +// The sentences of add-visit.spec.ts and of visit-vet.feature's glue, as plain functions: | |
| 5 | +// named for what the reader of a scenario wants to see, not for the widget being clicked. | |
| 6 | +// The selectors live here so neither the spec nor the glue mentions one. | |
| 7 | 7 | |
| 8 | 8 | const API_BASE = process.env.API_BASE_URL || 'http://localhost:8080/api'; |
| 9 | 9 | |
| 10 | +/** What a visit nobody attended reads as — `NO_VET` in petclinic-frontend's visit-vet.pipe.ts. */ | |
| 11 | +export const NO_VET = 'No vet'; | |
| 12 | + | |
| 10 | 13 | export interface OwnerWithPet { |
| 11 | 14 | ownerId: number; |
| 12 | 15 | petId: number; |
| export async function fill_visit_date_and_unique_description(page: Page, date: s | ||
| 39 | 42 | return description; |
| 40 | 43 | } |
| 41 | 44 | |
| 45 | +/** | |
| 46 | + * The vet field is there, and before anyone touches it it already says nobody: the form | |
| 47 | + * asks who will attend without forcing an answer. `No vet` is the label `visit-vet.pipe.ts` | |
| 48 | + * (NO_VET) gives the empty choice, so the booking form and the visit lists use one word. | |
| 49 | + */ | |
| 50 | +export async function expect_visit_form_offers_nobody_yet(page: Page): Promise<void> { | |
| 51 | + await expect(vetField(page).locator('option:checked')).toHaveText(NO_VET); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Picks the first real vet the form offers and returns the name it showed. "Real" is the | |
| 56 | + * first option after the empty one: `<app-combo>` binds its options by *index*, so the | |
| 57 | + * option values say nothing about which vet is which — the label is the only handle. | |
| 58 | + */ | |
| 59 | +export async function select_first_vet_in_visit_form(page: Page): Promise<string> { | |
| 60 | + const firstVet = vetField(page).locator('option:not([value=""])').first(); | |
| 61 | + const vetName = ((await firstVet.textContent()) || '').trim(); | |
| 62 | + expect(vetName, 'the booking form offers no vet to choose').not.toEqual(''); | |
| 63 | + await vetField(page).selectOption({label: vetName}); | |
| 64 | + return vetName; | |
| 65 | +} | |
| 66 | + | |
| 42 | 67 | export async function submit_visit_form(page: Page): Promise<void> { |
| 43 | 68 | await page.locator('button[type="submit"]:has-text("Add Visit")').click(); |
| 44 | 69 | } |
| export async function expect_back_on_owner_detail_page(page: Page, ownerId: numb | ||
| 49 | 74 | } |
| 50 | 75 | |
| 51 | 76 | export async function expect_pet_visit_list_contains(page: Page, date: string, description: string): Promise<void> { |
| 77 | + await expect(petVisitRow(page, date, description)).toBeVisible({timeout: 10_000}); | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** The owner's page names the vet on the visit's row — or says `No vet`, never a blank. */ | |
| 81 | +export async function expect_pet_visit_list_shows_vet( | |
| 82 | + page: Page, date: string, description: string, vet: string): Promise<void> { | |
| 83 | + await expect(petVisitRow(page, date, description).locator('td.visit-vet')).toHaveText(vet, {timeout: 10_000}); | |
| 84 | +} | |
| 85 | + | |
| 86 | +export async function open_all_visits_screen(page: Page): Promise<void> { | |
| 87 | + await page.goto('/visits'); | |
| 88 | + await page.locator('h2:has-text("Visits")').waitFor({state: 'visible', timeout: 10_000}); | |
| 89 | +} | |
| 90 | + | |
| 91 | +/** The all-visits screen names the same vet on the same visit (found by its unique description). */ | |
| 92 | +export async function expect_all_visits_screen_shows_vet( | |
| 93 | + page: Page, description: string, vet: string): Promise<void> { | |
| 94 | + const row = page.locator('#visitsTable tbody tr') | |
| 95 | + .filter({has: page.locator('td.visit-description', {hasText: description})}); | |
| 96 | + await expect(row.locator('td.visit-vet')).toHaveText(vet, {timeout: 10_000}); | |
| 97 | +} | |
| 98 | + | |
| 99 | +function vetField(page: Page) { | |
| 100 | + return page.locator('select#vet'); | |
| 101 | +} | |
| 102 | + | |
| 103 | +function petVisitRow(page: Page, date: string, description: string) { | |
| 52 | 104 | const petBlock = page.locator('app-pet-list').first(); |
| 53 | - const row = petBlock.locator('app-visit-list tr').filter({hasText: date}).filter({hasText: description}); | |
| 54 | - await expect(row).toBeVisible({timeout: 10_000}); | |
| 105 | + return petBlock.locator('app-visit-list tr').filter({hasText: date}).filter({hasText: description}); | |
| 55 | 106 | } |
| const { | ||
| 26 | 26 | // @generate_sequence turns this run into add-visit.spec.ts.<scenario>.genseq.puml, right here |
| 27 | 27 | // in this folder — one section per tagged test in the file. |
| 28 | 28 | |
| 29 | +// Books onto an owner's pet that is already there: the Default dataset's. | |
| 30 | +test.use({dbFixture: 'default'}); | |
| 31 | + | |
| 29 | 32 | const VISIT_DATE = '2026-05-12'; |
| 30 | 33 | |
| 31 | 34 | test('Add a visit to an existing pet from the owner detail page', |
| 1 | +import {test, expect} from './support/trace-fixture'; | |
| 2 | +import axios from 'axios'; | |
| 3 | + | |
| 4 | +// What an e2e test starts on, pinned down — the browser suites' half of the backend's | |
| 5 | +// StartingDatabaseTest: the reference rows alone when it declares nothing, the Default seed | |
| 6 | +// when it says `test.use({dbFixture: 'default'})`, and never what the test before it wrote. | |
| 7 | +// Only a docker stack can reset (the /__reset sidecar); on `ng serve` these are skipped. | |
| 8 | + | |
| 9 | +const BASE = (process.env.BASE_URL || 'http://127.0.0.1:4200').replace(/\/$/, ''); | |
| 10 | +const API = process.env.API_BASE_URL || 'http://localhost:8080/api'; | |
| 11 | + | |
| 12 | +test.beforeAll(async () => { | |
| 13 | + const res = await axios.get(`${BASE}/__reset`, {validateStatus: () => true, timeout: 5_000}) | |
| 14 | + .catch(() => undefined); | |
| 15 | + test.skip(res?.data?.ok !== true, `no reset sidecar behind ${BASE} — not a docker stack`); | |
| 16 | +}); | |
| 17 | + | |
| 18 | +type Owner = {id: number; firstName: string; lastName: string}; | |
| 19 | +const owners = async () => (await axios.get(`${API}/owners`)).data as Owner[]; | |
| 20 | + | |
| 21 | +test.describe('a test that declares no DB fixture', () => { | |
| 22 | + test('starts on an empty database, the reference pet types still there', async () => { | |
| 23 | + expect(await owners()).toEqual([]); | |
| 24 | + const {data: types} = await axios.get(`${API}/pettypes`); | |
| 25 | + expect(types.map((t: {name: string}) => t.name)) | |
| 26 | + .toEqual(['cat', 'dog', 'lizard', 'snake', 'bird', 'hamster', 'horse']); | |
| 27 | + | |
| 28 | + await axios.post(`${API}/owners`, {firstName: 'Left', lastName: 'Behind', address: 'Nowhere 1', | |
| 29 | + city: 'Gone', telephone: '0123456789'}); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + test('never sees what the test before it wrote', async () => { | |
| 33 | + expect(await owners()).toEqual([]); | |
| 34 | + }); | |
| 35 | +}); | |
| 36 | + | |
| 37 | +test.describe('a test that declares the Default fixture', () => { | |
| 38 | + test.use({dbFixture: 'default'}); | |
| 39 | + | |
| 40 | + test('starts on the seed: owner 1 is Kevin McCallister', async () => { | |
| 41 | + const kevin = (await owners()).find(o => o.id === 1); | |
| 42 | + expect(`${kevin?.firstName} ${kevin?.lastName}`).toBe('Kevin McCallister'); | |
| 43 | + }); | |
| 44 | +}); |
| 1 | +import {test, expect} from '@playwright/test'; | |
| 2 | +import * as fs from 'fs'; | |
| 3 | +import * as os from 'os'; | |
| 4 | +import * as path from 'path'; | |
| 5 | +import {SourceMapConsumer, SourceMapGenerator} from 'source-map'; | |
| 6 | +import { | |
| 7 | + CALL_SITE_ATTRIBUTE, CallFrame, REQUEST_HEADERS_ATTRIBUTE, applicationFrames, callSiteHandle, | |
| 8 | + callSiteIndex, idsOfTraceparent, parseStack, replayableHeaders, requestFrames, triggerOf, withCallSites, | |
| 9 | +} from './call-site'; | |
| 10 | +import type {NormSpan} from './trace-to-puml'; | |
| 11 | +import {CUCUMBER_SOURCES} from './generate'; | |
| 12 | +import {readCallSites, saveCallSites} from '../support/call-sites'; | |
| 13 | +import {forgetWindowsOf} from '../support/trace-window-store'; | |
| 14 | + | |
| 15 | +const BUNDLE = 'http://127.0.0.1:63285/main.5246b24f48d8b1e7.js'; | |
| 16 | +const POLYFILLS = 'http://127.0.0.1:63285/polyfills.96561b4e147d79cf.js'; | |
| 17 | +const COMPONENT = 'src/app/owners/owner-detail/owner-detail.component.ts'; | |
| 18 | +const SOURCE = [ | |
| 19 | + 'export class OwnerDetailComponent {', | |
| 20 | + ' ngOnInit() {', | |
| 21 | + ' this.ownerService.getOwnerById(ownerId).subscribe(', | |
| 22 | + ' owner => this.owner = owner);', | |
| 23 | + ' }', | |
| 24 | + '}', | |
| 25 | +].join('\n'); | |
| 26 | + | |
| 27 | +/** A bundle of one minified line: column 100 is the app, column 200 rxjs, 300 a lib's own `src/app`. */ | |
| 28 | +function bundleMap(sourceRoot = 'webpack:///', source = `./${COMPONENT}`): SourceMapConsumer { | |
| 29 | + const map = new SourceMapGenerator({file: 'main.js', sourceRoot}); | |
| 30 | + map.addMapping({source, name: 'subscribe', | |
| 31 | + generated: {line: 1, column: 99}, original: {line: 3, column: 44}}); | |
| 32 | + map.addMapping({source: './node_modules/rxjs/dist/esm/internal/Observable.js', | |
| 33 | + generated: {line: 1, column: 199}, original: {line: 30, column: 4}}); | |
| 34 | + map.addMapping({source: './node_modules/some-lib/src/app/lib.ts', | |
| 35 | + generated: {line: 1, column: 299}, original: {line: 7, column: 0}}); | |
| 36 | + map.setSourceContent(source, SOURCE); | |
| 37 | + return new SourceMapConsumer(JSON.parse(map.toString())); | |
| 38 | +} | |
| 39 | + | |
| 40 | +const stack = (...rows: string[]) => ['Error', ...rows.map((r) => ` at ${r}`)].join('\n'); | |
| 41 | + | |
| 42 | +test('the traceparent names the browser span the request left under', () => { | |
| 43 | + expect(idsOfTraceparent('00-f74df0671d03f3d3848ffda9ac315932-eb4261e7b2160a89-01')) | |
| 44 | + .toEqual({traceId: 'f74df0671d03f3d3848ffda9ac315932', spanId: 'eb4261e7b2160a89'}); | |
| 45 | + expect(idsOfTraceparent('not a traceparent')).toBeUndefined(); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +test('a stack reads top first, and a frame with no script position is skipped', () => { | |
| 49 | + expect(parseStack(stack( | |
| 50 | + 'proto.send [as __zone_symbol__send] (<anonymous>:24:42)', | |
| 51 | + `m.X [as scheduleFn] (${POLYFILLS}:1:32105)`, | |
| 52 | + `${POLYFILLS}:1:32496`, | |
| 53 | + ))).toEqual([ | |
| 54 | + {url: POLYFILLS, line: 1, column: 32105}, | |
| 55 | + {url: POLYFILLS, line: 1, column: 32496}, | |
| 56 | + ]); | |
| 57 | +}); | |
| 58 | + | |
| 59 | +test('only the application frames survive, at their original line', () => { | |
| 60 | + const frames = applicationFrames(stack( | |
| 61 | + `t.subscribe (${BUNDLE}:1:200)`, // rxjs | |
| 62 | + `lib (${BUNDLE}:1:300)`, // a library's own src/app | |
| 63 | + `e.ngOnInit (${BUNDLE}:1:100)`, | |
| 64 | + ), new Map([[BUNDLE, bundleMap()]])); | |
| 65 | + expect(frames).toEqual([{ | |
| 66 | + file: COMPONENT, line: 3, text: 'this.ownerService.getOwnerById(ownerId).subscribe(', | |
| 67 | + }]); | |
| 68 | +}); | |
| 69 | + | |
| 70 | +test('ng serve spells the source differently, and it is the same file', () => { | |
| 71 | + const frames = applicationFrames(stack(`e.ngOnInit (${BUNDLE}:1:100)`), | |
| 72 | + new Map([[BUNDLE, bundleMap('', `webpack:///./${COMPONENT}`)]])); | |
| 73 | + expect(frames.map((f) => f.file)).toEqual([COMPONENT]); | |
| 74 | +}); | |
| 75 | + | |
| 76 | +test('a script with no map gives no frames rather than a position in minified code', () => { | |
| 77 | + expect(applicationFrames(stack(`e.ngOnInit (${BUNDLE}:1:100)`), new Map())).toEqual([]); | |
| 78 | +}); | |
| 79 | + | |
| 80 | +test('two stack frames on one line are one frame', () => { | |
| 81 | + const frames = applicationFrames( | |
| 82 | + stack(`a (${BUNDLE}:1:100)`, `b (${BUNDLE}:1:101)`), new Map([[BUNDLE, bundleMap()]])); | |
| 83 | + expect(frames).toHaveLength(1); | |
| 84 | +}); | |
| 85 | + | |
| 86 | +// The router's hop: a stack of 226 frames with no application frame, and the click that | |
| 87 | +// scheduled it one task earlier. | |
| 88 | +test('a request sent from application code never looks at what scheduled it', () => { | |
| 89 | + const frames = requestFrames( | |
| 90 | + [stack(`e.ngOnInit (${BUNDLE}:1:100)`), stack(`e.earlier (${BUNDLE}:1:100)`)], | |
| 91 | + new Map([[BUNDLE, bundleMap()]])); | |
| 92 | + expect(frames).toEqual([expect.not.objectContaining({deferred: true})]); | |
| 93 | +}); | |
| 94 | + | |
| 95 | +test('a request with no application frame takes the nearest stack that scheduled it', () => { | |
| 96 | + const frames = requestFrames( | |
| 97 | + [stack(`t.subscribe (${BUNDLE}:1:200)`), stack(`e.onSort (${BUNDLE}:1:100)`)], | |
| 98 | + new Map([[BUNDLE, bundleMap()]])); | |
| 99 | + expect(frames).toEqual([expect.objectContaining({file: COMPONENT, line: 3, deferred: true})]); | |
| 100 | +}); | |
| 101 | + | |
| 102 | +test('no stack with an application frame means no frames', () => { | |
| 103 | + expect(requestFrames([stack(`t.subscribe (${BUNDLE}:1:200)`), 'Error'], | |
| 104 | + new Map([[BUNDLE, bundleMap()]]))).toEqual([]); | |
| 105 | +}); | |
| 106 | + | |
| 107 | +const frame = (file: string, line = 1): CallFrame => ({file, line}); | |
| 108 | + | |
| 109 | +test('the trigger is the first application frame: the component that subscribed', () => { | |
| 110 | + const component = frame('src/app/a/a.component.ts', 63); | |
| 111 | + expect(triggerOf([component, frame('src/app/a/a.component.html', 25)])).toBe(component); | |
| 112 | +}); | |
| 113 | + | |
| 114 | +test('a service above a component yields to the component that called it', () => { | |
| 115 | + const component = frame('src/app/a/a.component.ts'); | |
| 116 | + expect(triggerOf([frame('src/app/a/a.service.ts'), component])).toBe(component); | |
| 117 | + expect(triggerOf([frame('src/app/http-error.interceptor.ts'), component])).toBe(component); | |
| 118 | +}); | |
| 119 | + | |
| 120 | +test('a deferred request links to the handler, not the helper every caller shares', () => { | |
| 121 | + const file = 'src/app/visits/visits-page/visits-page.component.ts'; | |
| 122 | + const deferred = (f: string, line: number): CallFrame => ({file: f, line, deferred: true}); | |
| 123 | + const onSort = deferred(file, 63); | |
| 124 | + expect(triggerOf([deferred(file, 86), onSort, | |
| 125 | + deferred('src/app/visits/visits-page/visits-page.component.html', 15)])).toBe(onSort); | |
| 126 | +}); | |
| 127 | + | |
| 128 | +test('a service alone on the stack is still the best there is', () => { | |
| 129 | + const service = frame('src/app/a/a.service.ts'); | |
| 130 | + expect(triggerOf([service])).toBe(service); | |
| 131 | +}); | |
| 132 | + | |
| 133 | +test('no application frame, no trigger', () => { | |
| 134 | + expect(triggerOf([])).toBeUndefined(); | |
| 135 | +}); | |
| 136 | + | |
| 137 | +const ROOT = '/repo'; | |
| 138 | +const read = (files: Record<string, string>) => (p: string) => files[p]; | |
| 139 | +const attribute = (frames: CallFrame[]) => JSON.stringify(frames); | |
| 140 | + | |
| 141 | +test('the handle points at the confirmed line and says what the numbers are', () => { | |
| 142 | + const frames = [ | |
| 143 | + {file: COMPONENT, line: 3, text: 'this.ownerService.getOwnerById(ownerId).subscribe('}, | |
| 144 | + {file: 'src/app/owners/owner-detail/owner-detail.component.html', line: 25}, | |
| 145 | + ]; | |
| 146 | + expect(callSiteHandle(attribute(frames), 'petclinic-frontend', ROOT, | |
| 147 | + read({[`${ROOT}/petclinic-frontend/${COMPONENT}`]: SOURCE}))) | |
| 148 | + .toBe(`src://petclinic-frontend/${COMPONENT}:3` | |
| 149 | + + '{Click to open the line that sent this request: ' | |
| 150 | + + 'owner-detail.component.ts:3 ← owner-detail.component.html:25}'); | |
| 151 | +}); | |
| 152 | + | |
| 153 | +test('a line the checkout no longer has there gets no link, not the wrong one', () => { | |
| 154 | + const frames = [{file: COMPONENT, line: 2, text: 'this.ownerService.getOwnerById(ownerId).subscribe('}]; | |
| 155 | + expect(callSiteHandle(attribute(frames), 'petclinic-frontend', ROOT, | |
| 156 | + read({[`${ROOT}/petclinic-frontend/${COMPONENT}`]: SOURCE}))).toBeUndefined(); | |
| 157 | +}); | |
| 158 | + | |
| 159 | +test('a file this checkout does not have gets no link', () => { | |
| 160 | + expect(callSiteHandle(attribute([frame(COMPONENT)]), 'petclinic-frontend', ROOT, read({}))) | |
| 161 | + .toBeUndefined(); | |
| 162 | +}); | |
| 163 | + | |
| 164 | +test('frames that cannot be read get no link', () => { | |
| 165 | + expect(callSiteHandle('not json', 'petclinic-frontend', ROOT, read({}))).toBeUndefined(); | |
| 166 | + expect(callSiteHandle('[]', 'petclinic-frontend', ROOT, read({}))).toBeUndefined(); | |
| 167 | +}); | |
| 168 | + | |
| 169 | +function span(over: Partial<NormSpan>): NormSpan { | |
| 170 | + return {traceId: '', spanId: '', parentSpanId: '', name: 'GET', kind: 'CLIENT', | |
| 171 | + serviceName: 'petclinic-frontend', startNano: 0, attributes: {}, ...over}; | |
| 172 | +} | |
| 173 | + | |
| 174 | +test('Tempo’s base64 ids meet the header’s hex ones on the browser span', () => { | |
| 175 | + // Real ids from one run: 00-7c4ec1e2eb2f07d285a84ca5769679dd-96b94185f14defff-01 | |
| 176 | + const index = callSiteIndex([{ | |
| 177 | + traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff', frames: [frame(COMPONENT, 3)], | |
| 178 | + }]); | |
| 179 | + const [client, other] = withCallSites([ | |
| 180 | + span({traceId: 'fE7B4usvB9KFqEyldpZ53Q==', spanId: 'lrlBhfFN7/8='}), | |
| 181 | + span({traceId: 'fE7B4usvB9KFqEyldpZ53Q==', spanId: 'AAAAAAAAAAA='}), | |
| 182 | + ], index); | |
| 183 | + expect(JSON.parse(client.attributes[CALL_SITE_ATTRIBUTE])).toEqual([frame(COMPONENT, 3)]); | |
| 184 | + expect(other.attributes).toEqual({}); | |
| 185 | +}); | |
| 186 | + | |
| 187 | +test('only the request span is stamped, and a request with no frames stays bare', () => { | |
| 188 | + const ids = {traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff'}; | |
| 189 | + const located = callSiteIndex([{...ids, frames: [frame(COMPONENT)]}]); | |
| 190 | + expect(withCallSites([span({...ids, kind: 'SERVER'})], located)[0].attributes).toEqual({}); | |
| 191 | + expect(withCallSites([span(ids)], callSiteIndex([{...ids, frames: []}]))[0].attributes).toEqual({}); | |
| 192 | +}); | |
| 193 | + | |
| 194 | +// The headers ride on the span like the frames, so a re-render from the span cache has them. | |
| 195 | +test('a request’s headers are stamped beside its frames, and with no frames too', () => { | |
| 196 | + const ids = {traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff'}; | |
| 197 | + const headers = {Accept: 'application/json'}; | |
| 198 | + const [both] = withCallSites([span(ids)], callSiteIndex([{...ids, frames: [frame(COMPONENT)], headers}])); | |
| 199 | + expect(JSON.parse(both.attributes[REQUEST_HEADERS_ATTRIBUTE])).toEqual(headers); | |
| 200 | + expect(both.attributes[CALL_SITE_ATTRIBUTE]).toBeDefined(); | |
| 201 | + const [headersOnly] = withCallSites([span(ids)], callSiteIndex([{...ids, frames: [], headers}])); | |
| 202 | + expect(headersOnly.attributes).toEqual({[REQUEST_HEADERS_ATTRIBUTE]: JSON.stringify(headers)}); | |
| 203 | +}); | |
| 204 | + | |
| 205 | +// The trace context is new on every request of every run: kept, it would repaint every sidecar. | |
| 206 | +test('the per-run trace context is not a header worth keeping', () => { | |
| 207 | + expect(replayableHeaders({traceparent: '00-a-b-01', TraceState: 'x', baggage: 'y', Accept: '*/*'})) | |
| 208 | + .toEqual({Accept: '*/*'}); | |
| 209 | + expect(replayableHeaders({traceparent: '00-a-b-01'})).toBeUndefined(); | |
| 210 | + expect(replayableHeaders(undefined)).toBeUndefined(); | |
| 211 | +}); | |
| 212 | + | |
| 213 | +test('a saved request keeps its headers, less the trace context', async () => { | |
| 214 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'call-sites-')); | |
| 215 | + const traceparent = '00-7c4ec1e2eb2f07d285a84ca5769679dd-96b94185f14defff-01'; | |
| 216 | + await saveCallSites(dir, {title: 'Add a visit', source: 'src/add-visit.spec.ts'}, [ | |
| 217 | + {traceparent, stacks: ['Error'], headers: {traceparent, 'Content-Type': 'application/json'}}, | |
| 218 | + {traceparent: traceparent.replace('ff-01', 'fe-01'), stacks: ['Error'], headers: {traceparent}}, | |
| 219 | + ]); | |
| 220 | + expect(readCallSites(dir).map((s) => s.headers)).toEqual([{'Content-Type': 'application/json'}, undefined]); | |
| 221 | +}); | |
| 222 | + | |
| 223 | +// A Scenario Outline runs every row under one title, so one test can save twice in a run. | |
| 224 | +test('every row of an Outline adds to its file, and a runner forgets only its own', async () => { | |
| 225 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'call-sites-')); | |
| 226 | + const outline = {title: 'Filter owners', source: 'src/owner-search.feature'}; | |
| 227 | + const raw = (spanId: string) => ({ | |
| 228 | + traceparent: `00-7c4ec1e2eb2f07d285a84ca5769679dd-${spanId}-01`, stacks: ['Error'], | |
| 229 | + }); | |
| 230 | + await saveCallSites(dir, outline, [raw('96b94185f14defff')]); | |
| 231 | + await saveCallSites(dir, outline, [raw('96b94185f14deffe')]); | |
| 232 | + await saveCallSites(dir, {title: 'Add a visit', source: 'src/add-visit.spec.ts'}, [raw('96b94185f14deffd')]); | |
| 233 | + expect(readCallSites(dir).map((s) => s.spanId).sort()) | |
| 234 | + .toEqual(['96b94185f14deffd', '96b94185f14deffe', '96b94185f14defff']); | |
| 235 | + | |
| 236 | + forgetWindowsOf(dir, CUCUMBER_SOURCES); | |
| 237 | + expect(readCallSites(dir).map((s) => s.spanId)).toEqual(['96b94185f14deffd']); | |
| 238 | +}); |
| 1 | +import type {SourceMapConsumer} from 'source-map'; | |
| 2 | +import type {NormSpan} from './trace-to-puml'; | |
| 3 | +import type {ReadSource} from './code-location'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Which line of the frontend sent this request, in this run. | |
| 7 | + * | |
| 8 | + * The same endpoint is called from several places, so scanning the source for a route would | |
| 9 | + * be a guess the diagram then states as a fact. The answer is taken from the request itself: | |
| 10 | + * `support/call-sites.ts` captures the JS stack at the moment each XHR or fetch leaves, keyed | |
| 11 | + * by the `traceparent` it carries, and everything here turns that stack into a link. | |
| 12 | + * | |
| 13 | + * Measured on add-visit: the stack is 76–89 frames deep and the application frame sits around | |
| 14 | + * the 48th, under rxjs, HttpClient, zone and OTel. `http.get` is a cold Observable, so the | |
| 15 | + * service method has already returned: `OwnerService.getOwnerById` is never on the stack, and | |
| 16 | + * the top application frame is the component's `.subscribe(`. A request the router deferred | |
| 17 | + * has no application frame at all, and is explained by what scheduled it (`requestFrames`). | |
| 18 | + */ | |
| 19 | + | |
| 20 | +/** Where a browser CLIENT span keeps its frames, as JSON — so a replay of the span cache has them. */ | |
| 21 | +export const CALL_SITE_ATTRIBUTE = 'genseq.call-site'; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * Where a browser CLIENT span keeps the headers its request was sent with, as a JSON object. | |
| 25 | + * The browser instrumentation records none, and the request's detail panel shows them and | |
| 26 | + * replays the call with them (Victor, 10 Nov 2026); on the span for the same reason as the frames. | |
| 27 | + */ | |
| 28 | +export const REQUEST_HEADERS_ATTRIBUTE = 'genseq.request-headers'; | |
| 29 | + | |
| 30 | +/** One application frame of a request's stack, in the original source. */ | |
| 31 | +export interface CallFrame { | |
| 32 | + /** Relative to the frontend module: `src/app/owners/owner.service.ts`. */ | |
| 33 | + file: string; | |
| 34 | + line: number; | |
| 35 | + /** That line of the source the bundle was built from, to confirm against the checkout. */ | |
| 36 | + text?: string; | |
| 37 | + /** Not on the request's own stack: it comes from the code that scheduled the task it left in. */ | |
| 38 | + deferred?: boolean; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** One request, identified the way its spans are: the header it left the page with. */ | |
| 42 | +export interface CallSite { | |
| 43 | + traceId: string; | |
| 44 | + spanId: string; | |
| 45 | + frames: CallFrame[]; | |
| 46 | + /** Every header the page set on the request, as it set them — less the per-run trace context. */ | |
| 47 | + headers?: Record<string, string>; | |
| 48 | +} | |
| 49 | + | |
| 50 | +// The trace context is new on every request of every run: stored, it would repaint each | |
| 51 | +// diagram's sidecar on every run, and a replay sent with it would join a trace long closed. | |
| 52 | +const PER_RUN_HEADERS = new Set(['traceparent', 'tracestate', 'baggage']); | |
| 53 | + | |
| 54 | +/** The headers worth keeping, or undefined when none are left. */ | |
| 55 | +export function replayableHeaders( | |
| 56 | + headers: Record<string, string> | undefined, | |
| 57 | +): Record<string, string> | undefined { | |
| 58 | + const kept = Object.entries(headers ?? {}).filter(([name]) => !PER_RUN_HEADERS.has(name.toLowerCase())); | |
| 59 | + return kept.length > 0 ? Object.fromEntries(kept) : undefined; | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** `00-<trace id>-<span id>-01` — the span id in it is the browser's CLIENT span. */ | |
| 63 | +export function idsOfTraceparent( | |
| 64 | + traceparent: string, | |
| 65 | +): Pick<CallSite, 'traceId' | 'spanId'> | undefined { | |
| 66 | + const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(traceparent.trim()); | |
| 67 | + return m ? {traceId: m[1], spanId: m[2]} : undefined; | |
| 68 | +} | |
| 69 | + | |
| 70 | +const HEX_ID = /^(?:[0-9a-f]{16}|[0-9a-f]{32})$/; | |
| 71 | + | |
| 72 | +/** Tempo answers with base64 ids; a `traceparent` carries them in hex. */ | |
| 73 | +function hexId(id: string): string { | |
| 74 | + return HEX_ID.test(id) ? id : Buffer.from(id, 'base64').toString('hex'); | |
| 75 | +} | |
| 76 | + | |
| 77 | +/** The script positions of a V8 stack, top first. Frames in eval or the init script have none. */ | |
| 78 | +export function parseStack(stack: string): Array<{url: string; line: number; column: number}> { | |
| 79 | + const frames: Array<{url: string; line: number; column: number}> = []; | |
| 80 | + for (const row of stack.split('\n')) { | |
| 81 | + const m = /(https?:\/\/[^\s()]+):(\d+):(\d+)\)?\s*$/.exec(row); | |
| 82 | + if (m) frames.push({url: m[1], line: Number(m[2]), column: Number(m[3])}); | |
| 83 | + } | |
| 84 | + return frames; | |
| 85 | +} | |
| 86 | + | |
| 87 | +// `webpack:///src/app/…` in a production bundle, `webpack:///./src/app/…` from `ng serve`. | |
| 88 | +// Anchored, because `webpack:///node_modules/some-lib/src/app/…` is somebody else's app. | |
| 89 | +const APPLICATION_SOURCE = /^(?:webpack:\/\/[^/]*\/)?(?:\.\/)?(src\/app\/[^?]+)/; | |
| 90 | + | |
| 91 | +/** | |
| 92 | + * The application's own frames of one stack, top first, in the original source. A script with | |
| 93 | + * no map contributes nothing: no frame is better than a position in minified code. | |
| 94 | + */ | |
| 95 | +export function applicationFrames( | |
| 96 | + stack: string, maps: ReadonlyMap<string, SourceMapConsumer>, | |
| 97 | +): CallFrame[] { | |
| 98 | + const frames: CallFrame[] = []; | |
| 99 | + for (const {url, line, column} of parseStack(stack)) { | |
| 100 | + const map = maps.get(url); | |
| 101 | + if (!map) continue; | |
| 102 | + // A stack counts columns from 1, a source map from 0. | |
| 103 | + const at = map.originalPositionFor({line, column: column - 1}); | |
| 104 | + const file = at.source ? APPLICATION_SOURCE.exec(at.source)?.[1] : undefined; | |
| 105 | + if (!file || !at.source || !at.line) continue; | |
| 106 | + const previous = frames[frames.length - 1]; | |
| 107 | + if (previous?.file === file && previous.line === at.line) continue; | |
| 108 | + const text = map.sourceContentFor(at.source, true)?.split('\n')[at.line - 1]?.trim(); | |
| 109 | + frames.push({file, line: at.line, ...(text ? {text} : {})}); | |
| 110 | + } | |
| 111 | + return frames; | |
| 112 | +} | |
| 113 | + | |
| 114 | +/** | |
| 115 | + * The frames that explain one request: its own stack's, or else those of the nearest stack | |
| 116 | + * that scheduled the task it left in. Causes are a fallback, so a request that was sent from | |
| 117 | + * application code is never told apart from it by something that happened earlier. | |
| 118 | + */ | |
| 119 | +export function requestFrames( | |
| 120 | + stacks: string[], maps: ReadonlyMap<string, SourceMapConsumer>, | |
| 121 | +): CallFrame[] { | |
| 122 | + for (const [hop, stack] of stacks.entries()) { | |
| 123 | + const frames = applicationFrames(stack, maps); | |
| 124 | + if (frames.length > 0) return hop === 0 ? frames : frames.map((f) => ({...f, deferred: true})); | |
| 125 | + } | |
| 126 | + return []; | |
| 127 | +} | |
| 128 | + | |
| 129 | +// Code that passes a request along. Above a component on the stack (a service that subscribes | |
| 130 | +// on the component's behalf), it says how the request travelled, not what started it. | |
| 131 | +const PASS_THROUGH = /\.(?:service|interceptor)\.ts$/; | |
| 132 | + | |
| 133 | +/** | |
| 134 | + * The frame an arrow links to: the application frame nearest what started the request. | |
| 135 | + * Usually the first frame there is; a service above a component yields to the component. | |
| 136 | + * | |
| 137 | + * A deferred request counts from the other end. Its first frame is whatever handed the work | |
| 138 | + * over, often one helper every caller shares (`navigate()` behind both a sort and a page | |
| 139 | + * change), and the deepest code frame is the handler the template called: `onSort`, `onPage`. | |
| 140 | + */ | |
| 141 | +export function triggerOf(frames: CallFrame[]): CallFrame | undefined { | |
| 142 | + const code = (f: CallFrame) => !PASS_THROUGH.test(f.file) && !f.file.endsWith('.html'); | |
| 143 | + if (frames[0]?.deferred) return [...frames].reverse().find(code) ?? frames[0]; | |
| 144 | + return frames.find((f) => !PASS_THROUGH.test(f.file)) ?? frames[0]; | |
| 145 | +} | |
| 146 | + | |
| 147 | +const fileName = (frame: CallFrame) => `${frame.file.split('/').pop()}:${frame.line}`; | |
| 148 | + | |
| 149 | +/** Each frame by file and line, not by function: a minified stack has no names worth showing. */ | |
| 150 | +function callSiteTooltip(frames: CallFrame[]): string { | |
| 151 | + return `{Click to open the line that sent this request: ${frames.map(fileName).join(' ← ')}}`; | |
| 152 | +} | |
| 153 | + | |
| 154 | +function framesOf(attribute: string): CallFrame[] { | |
| 155 | + try { | |
| 156 | + return JSON.parse(attribute); | |
| 157 | + } catch { | |
| 158 | + return []; | |
| 159 | + } | |
| 160 | +} | |
| 161 | + | |
| 162 | +/** | |
| 163 | + * A `src://` handle on the line that sent this request, or undefined when there is no telling. | |
| 164 | + * | |
| 165 | + * The line is confirmed against the checkout: the bundle may have been built from another | |
| 166 | + * commit than the one the page is reviewing, and there the same number is a different line. | |
| 167 | + * Better no link than one that lands on the wrong code. | |
| 168 | + */ | |
| 169 | +export function callSiteHandle( | |
| 170 | + attribute: string, serviceName: string, repoRoot: string, readSource: ReadSource, | |
| 171 | +): string | undefined { | |
| 172 | + const frames = framesOf(attribute); | |
| 173 | + const trigger = triggerOf(frames); | |
| 174 | + if (!trigger) return undefined; | |
| 175 | + const relative = `${serviceName}/${trigger.file}`; | |
| 176 | + const lines = readSource(`${repoRoot}/${relative}`)?.split('\n'); | |
| 177 | + const line = lines?.[trigger.line - 1]; | |
| 178 | + if (line === undefined) return undefined; | |
| 179 | + if (trigger.text !== undefined && line.trim() !== trigger.text) return undefined; | |
| 180 | + return `src://${relative}:${trigger.line}${callSiteTooltip(frames)}`; | |
| 181 | +} | |
| 182 | + | |
| 183 | +/** Keyed the same way on both sides of the join, whatever spelling the ids arrived in. */ | |
| 184 | +function joinKey(ids: Pick<CallSite, 'traceId' | 'spanId'>): string { | |
| 185 | + return `${hexId(ids.traceId)}/${hexId(ids.spanId)}`; | |
| 186 | +} | |
| 187 | + | |
| 188 | +/** What one recorded request adds to its span. */ | |
| 189 | +export type RecordedSite = Pick<CallSite, 'frames' | 'headers'>; | |
| 190 | + | |
| 191 | +/** Every recorded request, by the span it left the browser under. */ | |
| 192 | +export function callSiteIndex(sites: CallSite[]): Map<string, RecordedSite> { | |
| 193 | + return new Map(sites.map((site) => [joinKey(site), {frames: site.frames, headers: site.headers}])); | |
| 194 | +} | |
| 195 | + | |
| 196 | +/** | |
| 197 | + * Stamps each browser CLIENT span with the frames of the request it made, and its headers. | |
| 198 | + * | |
| 199 | + * On the span rather than beside it, so the span cache carries them: a re-render replays that | |
| 200 | + * file with nothing running, long after the recordings and the source maps are gone. | |
| 201 | + */ | |
| 202 | +export function withCallSites(spans: NormSpan[], index: Map<string, RecordedSite>): NormSpan[] { | |
| 203 | + return spans.map((span) => { | |
| 204 | + const site = span.kind === 'CLIENT' ? index.get(joinKey(span)) : undefined; | |
| 205 | + const headers = replayableHeaders(site?.headers); | |
| 206 | + const stamped: Record<string, string> = { | |
| 207 | + ...(site?.frames?.length ? {[CALL_SITE_ATTRIBUTE]: JSON.stringify(site.frames)} : {}), | |
| 208 | + ...(headers ? {[REQUEST_HEADERS_ATTRIBUTE]: JSON.stringify(headers)} : {}), | |
| 209 | + }; | |
| 210 | + if (Object.keys(stamped).length === 0) return span; | |
| 211 | + return {...span, attributes: {...span.attributes, ...stamped}}; | |
| 212 | + }); | |
| 213 | +} |
| 1 | +import {expect, test} from '@playwright/test'; | |
| 2 | +import {clientMethodOf, maskSource} from './client-method'; | |
| 3 | + | |
| 4 | +// The frontend method a request went through, read back from the source: the stack only | |
| 5 | +// has the component's `.subscribe(` line, since `http.get` is a cold Observable. | |
| 6 | + | |
| 7 | +const SERVICE = ` | |
| 8 | +import {HttpClient} from '@angular/common/http'; | |
| 9 | +@Injectable() | |
| 10 | +export class OwnerService { | |
| 11 | + entityUrl = environment.REST_API_URL + 'owners'; | |
| 12 | + constructor(private http: HttpClient) {} | |
| 13 | + | |
| 14 | + getOwners(): Observable<Owner[]> { | |
| 15 | + return this.http | |
| 16 | + .get<Owner[]>(this.entityUrl) | |
| 17 | + .pipe(catchError(this.handlerError('getOwners', []))); | |
| 18 | + } | |
| 19 | + | |
| 20 | + getOwnerById(ownerId: number): Observable<Owner> { | |
| 21 | + return this.http.get<Owner>(this.entityUrl + '/' + ownerId); | |
| 22 | + } | |
| 23 | + | |
| 24 | + addOwner(owner: Owner): Observable<Owner> { | |
| 25 | + return this.http.post<Owner>(this.entityUrl, owner); | |
| 26 | + } | |
| 27 | + | |
| 28 | + searchOwners(lastName: string): Observable<Owner[]> { | |
| 29 | + let url = this.entityUrl; | |
| 30 | + if (lastName !== undefined) { | |
| 31 | + url += '?lastName=' + lastName; | |
| 32 | + } | |
| 33 | + return this.http.get<Owner[]>(url).pipe(catchError(this.handlerError('searchOwners', []))); | |
| 34 | + } | |
| 35 | +} | |
| 36 | +`; | |
| 37 | + | |
| 38 | +const COMPONENT = `import {Component, OnInit} from '@angular/core'; | |
| 39 | +import {OwnerService} from '../owner.service'; | |
| 40 | +import {Router} from '@angular/router'; | |
| 41 | + | |
| 42 | +export class OwnerListComponent implements OnInit { | |
| 43 | + constructor(private router: Router, private ownerService: OwnerService) { | |
| 44 | + } | |
| 45 | + | |
| 46 | + ngOnInit() { | |
| 47 | + this.load = this.ownerService.getOwners().pipe( | |
| 48 | + finalize(() => { | |
| 49 | + this.isOwnersDataReceived = true; | |
| 50 | + }) | |
| 51 | + ).subscribe( | |
| 52 | + owners => this.owners = owners); | |
| 53 | + } | |
| 54 | + | |
| 55 | + searchByLastName(lastName: string) { | |
| 56 | + this.load?.unsubscribe(); | |
| 57 | + const owners$ = lastName ? this.ownerService.searchOwners(lastName) : this.ownerService.getOwners(); | |
| 58 | + this.load = owners$.subscribe( | |
| 59 | + owners => this.owners = owners); | |
| 60 | + } | |
| 61 | + | |
| 62 | + onSelect(owner: Owner) { | |
| 63 | + this.router.navigate(['/owners', owner.id]); | |
| 64 | + } | |
| 65 | + | |
| 66 | + ngOnChanges() { | |
| 67 | + this.ownerService.getOwnerById(1).subscribe(owner => { | |
| 68 | + this.ownerService.addOwner(owner).subscribe( | |
| 69 | + () => {}); | |
| 70 | + }); | |
| 71 | + } | |
| 72 | +} | |
| 73 | +`; | |
| 74 | + | |
| 75 | +const ROOT = '/repo/petclinic-frontend'; | |
| 76 | +const COMPONENT_FILE = 'src/app/owners/owner-list/owner-list.component.ts'; | |
| 77 | +const files: Record<string, string> = { | |
| 78 | + [`${ROOT}/${COMPONENT_FILE}`]: COMPONENT, | |
| 79 | + [`${ROOT}/src/app/owners/owner.service.ts`]: SERVICE, | |
| 80 | +}; | |
| 81 | +const read = (p: string) => files[p]; | |
| 82 | +const lineOf = (needle: string) => COMPONENT.split('\n').findIndex((l) => l.includes(needle)) + 1; | |
| 83 | +const frame = (needle: string) => ({file: COMPONENT_FILE, line: lineOf(needle), text: needle}); | |
| 84 | +const URL = 'http://127.0.0.1:8080/api/owners'; | |
| 85 | + | |
| 86 | +test('a subscribe on the service call names the service method', () => { | |
| 87 | + expect(clientMethodOf([frame('this.ownerService.getOwnerById(1).subscribe(owner => {')], | |
| 88 | + {verb: 'GET', url: `${URL}/1`}, ROOT, read)).toBe('OwnerService.getOwnerById'); | |
| 89 | +}); | |
| 90 | + | |
| 91 | +test('a subscribe at the end of a pipe chain reads the statement back to its start', () => { | |
| 92 | + expect(clientMethodOf([frame(').subscribe(')], {verb: 'GET', url: URL}, ROOT, read)) | |
| 93 | + .toBe('OwnerService.getOwners'); | |
| 94 | +}); | |
| 95 | + | |
| 96 | +test('a call nested in another call’s callback is its own statement', () => { | |
| 97 | + expect(clientMethodOf([frame('this.ownerService.addOwner(owner).subscribe(')], | |
| 98 | + {verb: 'POST', url: URL}, ROOT, read)).toBe('OwnerService.addOwner'); | |
| 99 | +}); | |
| 100 | + | |
| 101 | +test('a local observable is followed to the assignment that built it, and the URL picks the branch', () => { | |
| 102 | + const sent = frame('this.load = owners$.subscribe('); | |
| 103 | + expect(clientMethodOf([sent], {verb: 'GET', url: `${URL}?lastName=Dav`}, ROOT, read)) | |
| 104 | + .toBe('OwnerService.searchOwners'); | |
| 105 | + expect(clientMethodOf([sent], {verb: 'GET', url: URL}, ROOT, read)) | |
| 106 | + .toBe('OwnerService.getOwners'); | |
| 107 | +}); | |
| 108 | + | |
| 109 | +test('no service call on the line names the component method it is written in', () => { | |
| 110 | + expect(clientMethodOf([frame('this.router.navigate([\'/owners\', owner.id]);')], | |
| 111 | + {verb: 'GET', url: URL}, ROOT, read)).toBe('OwnerListComponent.onSelect'); | |
| 112 | +}); | |
| 113 | + | |
| 114 | +test('a line the checkout no longer has is named by file and line, never read into', () => { | |
| 115 | + expect(clientMethodOf([{file: COMPONENT_FILE, line: lineOf(').subscribe('), text: 'gone();'}], | |
| 116 | + {verb: 'GET', url: URL}, ROOT, read)).toBe(`owner-list.component.ts:${lineOf(').subscribe(')}`); | |
| 117 | +}); | |
| 118 | + | |
| 119 | +test('no call site, no self-call', () => { | |
| 120 | + expect(clientMethodOf([], {verb: 'GET', url: URL}, ROOT, read)).toBeUndefined(); | |
| 121 | +}); | |
| 122 | + | |
| 123 | +test('masking keeps every offset and blanks strings and comments', () => { | |
| 124 | + const source = `a('x;y') // b(\n/* c( */ d("e")`; | |
| 125 | + const masked = maskSource(source); | |
| 126 | + expect(masked.length).toBe(source.length); | |
| 127 | + expect(masked).toBe(`a(' ') \n d(" ")`); | |
| 128 | +}); |
| 1 | +import type {CallFrame} from './call-site'; | |
| 2 | +import type {ReadSource} from './code-location'; | |
| 3 | +import {triggerOf} from './call-site'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * Which frontend method a browser request was made through: `OwnerService.getOwnerById`. | |
| 7 | + * | |
| 8 | + * The diagram draws it as a call arriving on the Browser lifeline from the left edge, the | |
| 9 | + * request leaving from inside its activation, the way a repository call on the Backend fires | |
| 10 | + * its queries (Victor, 10 Nov 2026). The stack cannot say it: `http.get` is a cold Observable, so the | |
| 11 | + * service method has returned by the time the request leaves, and the top application frame | |
| 12 | + * is the component's `.subscribe(` (see call-site.ts). The source can. | |
| 13 | + * | |
| 14 | + * From the line that subscribed, the statement it ends is read back, and every | |
| 15 | + * `this.<field>.<method>(` in it whose field is typed with a class this app imports is a | |
| 16 | + * candidate; a statement that subscribes to a local (`owners$.subscribe(`) is followed to | |
| 17 | + * the assignment that filled it. One candidate is the answer. Several — `lastName ? | |
| 18 | + * searchOwners(…) : getOwners()` — are told apart by the request itself: the HTTP verb the | |
| 19 | + * method's body calls, then which of its string literals the URL carries (`'?lastName='`). | |
| 20 | + * | |
| 21 | + * No answer is still a name, never a guess: the component method the line sits in | |
| 22 | + * (`OwnerListComponent.searchByLastName`), or else the file and line. | |
| 23 | + */ | |
| 24 | + | |
| 25 | +/** The request as the browser's CLIENT span recorded it. */ | |
| 26 | +export interface SentRequest { | |
| 27 | + verb?: string; | |
| 28 | + url?: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** Blanks strings and comments, keeping every offset, so a scan never matches inside one. */ | |
| 32 | +export function maskSource(source: string): string { | |
| 33 | + let out = ''; | |
| 34 | + let i = 0; | |
| 35 | + while (i < source.length) { | |
| 36 | + const ch = source[i]; | |
| 37 | + const next = source[i + 1]; | |
| 38 | + let end = i + 1; | |
| 39 | + if (ch === '/' && next === '/') { | |
| 40 | + end = source.indexOf('\n', i); | |
| 41 | + if (end < 0) end = source.length; | |
| 42 | + } else if (ch === '/' && next === '*') { | |
| 43 | + end = source.indexOf('*/', i + 2); | |
| 44 | + end = end < 0 ? source.length : end + 2; | |
| 45 | + } else if (ch === '\'' || ch === '"' || ch === '`') { | |
| 46 | + end = i + 1; | |
| 47 | + while (end < source.length && source[end] !== ch) end += source[end] === '\\' ? 2 : 1; | |
| 48 | + end = Math.min(end + 1, source.length); | |
| 49 | + out += ch + source.slice(i + 1, end - 1).replace(/[^\n]/g, ' ') + (end - 1 > i ? ch : ''); | |
| 50 | + i = end; | |
| 51 | + continue; | |
| 52 | + } else { | |
| 53 | + out += ch; | |
| 54 | + i++; | |
| 55 | + continue; | |
| 56 | + } | |
| 57 | + out += source.slice(i, end).replace(/[^\n]/g, ' '); | |
| 58 | + i = end; | |
| 59 | + } | |
| 60 | + return out; | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** Offset of the first character of a 1-based line. */ | |
| 64 | +function offsetOfLine(source: string, line: number): number { | |
| 65 | + let at = 0; | |
| 66 | + for (let n = 1; n < line && at >= 0; n++) at = source.indexOf('\n', at) + 1; | |
| 67 | + return at; | |
| 68 | +} | |
| 69 | + | |
| 70 | +/** | |
| 71 | + * Where the statement that `at` sits in begins: walking back over balanced brackets, it is | |
| 72 | + * the character after a `;` or an unmatched opening bracket — the `{` of the function body | |
| 73 | + * or the `(` of the callback the statement is nested in. | |
| 74 | + */ | |
| 75 | +function statementStart(masked: string, at: number): number { | |
| 76 | + let depth = 0; | |
| 77 | + for (let i = at - 1; i >= 0; i--) { | |
| 78 | + const ch = masked[i]; | |
| 79 | + if (ch === ')' || ch === ']' || ch === '}') depth++; | |
| 80 | + else if (ch === '(' || ch === '[' || ch === '{') { | |
| 81 | + if (depth === 0) return i + 1; | |
| 82 | + depth--; | |
| 83 | + } else if (ch === ';' && depth === 0) return i + 1; | |
| 84 | + } | |
| 85 | + return 0; | |
| 86 | +} | |
| 87 | + | |
| 88 | +/** Where the statement starting at `from` ends: its `;` or the bracket that closes around it. */ | |
| 89 | +function statementEnd(masked: string, from: number): number { | |
| 90 | + let depth = 0; | |
| 91 | + for (let i = from; i < masked.length; i++) { | |
| 92 | + const ch = masked[i]; | |
| 93 | + if (ch === '(' || ch === '[' || ch === '{') depth++; | |
| 94 | + else if (ch === ')' || ch === ']' || ch === '}') { | |
| 95 | + if (depth === 0) return i; | |
| 96 | + depth--; | |
| 97 | + } else if (ch === ';' && depth === 0) return i; | |
| 98 | + } | |
| 99 | + return masked.length; | |
| 100 | +} | |
| 101 | + | |
| 102 | +const SERVICE_CALL = /\bthis\s*\.\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*(?:<[^()]*?>)?\s*\(/g; | |
| 103 | +const LOCAL_RECEIVER = /(^|[^\w$.])([A-Za-z_$][\w$]*)\s*\.\s*(?:pipe|subscribe)\s*\(/g; | |
| 104 | + | |
| 105 | +interface Candidate { | |
| 106 | + type: string; | |
| 107 | + method: string; | |
| 108 | +} | |
| 109 | + | |
| 110 | +function callsIn(masked: string, from: number, to: number): Candidate[] { | |
| 111 | + const calls: Candidate[] = []; | |
| 112 | + for (const m of masked.slice(from, to).matchAll(SERVICE_CALL)) calls.push({type: m[1], method: m[2]}); | |
| 113 | + return calls; | |
| 114 | +} | |
| 115 | + | |
| 116 | +function escapeRegExp(s: string): string { | |
| 117 | + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| 118 | +} | |
| 119 | + | |
| 120 | +/** The candidates of the statement around `at`, by field name for now. */ | |
| 121 | +function calledFrom(masked: string, at: number): Candidate[] { | |
| 122 | + const start = statementStart(masked, at); | |
| 123 | + const calls = callsIn(masked, start, at); | |
| 124 | + if (calls.length > 0) return calls; | |
| 125 | + // `this.load = owners$.subscribe(`: the request was built where the local was filled. | |
| 126 | + const lineEnd = masked.indexOf('\n', at); | |
| 127 | + for (const m of masked.slice(start, lineEnd < 0 ? masked.length : lineEnd).matchAll(LOCAL_RECEIVER)) { | |
| 128 | + const local = m[2]; | |
| 129 | + if (local === 'this') continue; | |
| 130 | + const assigned = new RegExp(`(?:^|[^\\w$.])${escapeRegExp(local)}\\s*=(?!=)`, 'g'); | |
| 131 | + let last: number | undefined; | |
| 132 | + for (const a of masked.slice(0, start).matchAll(assigned)) last = a.index! + a[0].length; | |
| 133 | + if (last !== undefined) return callsIn(masked, last, statementEnd(masked, last)); | |
| 134 | + } | |
| 135 | + return []; | |
| 136 | +} | |
| 137 | + | |
| 138 | +/** `ownerService` → `OwnerService`, from its declaration: a constructor parameter, a field, `inject(…)`. */ | |
| 139 | +function typeOfField(masked: string, field: string): string | undefined { | |
| 140 | + const name = escapeRegExp(field); | |
| 141 | + const typed = new RegExp(`(?:^|[^\\w$.])${name}\\s*[?!]?\\s*:\\s*([A-Z][\\w$]*)`).exec(masked); | |
| 142 | + if (typed) return typed[1]; | |
| 143 | + return new RegExp(`(?:^|[^\\w$.])${name}\\s*=\\s*inject\\s*\\(\\s*([A-Z][\\w$]*)`).exec(masked)?.[1]; | |
| 144 | +} | |
| 145 | + | |
| 146 | +/** The file a class is imported from, when the import is this app's own (relative). */ | |
| 147 | +function importedFile(source: string, type: string, fromFile: string): string | undefined { | |
| 148 | + for (const m of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) { | |
| 149 | + const names = m[1].split(',').map((n) => n.trim().split(/\s+as\s+/).pop()); | |
| 150 | + if (!names.includes(type) || !m[2].startsWith('.')) continue; | |
| 151 | + const parts = fromFile.split('/').slice(0, -1); | |
| 152 | + for (const segment of `${m[2]}.ts`.split('/')) { | |
| 153 | + if (segment === '..') parts.pop(); | |
| 154 | + else if (segment !== '.') parts.push(segment); | |
| 155 | + } | |
| 156 | + return parts.join('/'); | |
| 157 | + } | |
| 158 | + return undefined; | |
| 159 | +} | |
| 160 | + | |
| 161 | +/** The body of `method` as declared in a class's source, or undefined when it is not. */ | |
| 162 | +function bodyOf(source: string, method: string): string | undefined { | |
| 163 | + const masked = maskSource(source); | |
| 164 | + const declared = new RegExp( | |
| 165 | + `^[ \\t]*(?:(?:public|private|protected|async|static|override)\\s+)*${escapeRegExp(method)}` | |
| 166 | + + '\\s*(?:<[^()]*?>)?\\s*\\(', 'm').exec(masked); | |
| 167 | + if (!declared) return undefined; | |
| 168 | + const open = masked.indexOf('{', declared.index + declared[0].length); | |
| 169 | + if (open < 0) return undefined; | |
| 170 | + let depth = 0; | |
| 171 | + for (let i = open; i < masked.length; i++) { | |
| 172 | + if (masked[i] === '{') depth++; | |
| 173 | + else if (masked[i] === '}' && --depth === 0) return source.slice(open, i + 1); | |
| 174 | + } | |
| 175 | + return source.slice(open); | |
| 176 | +} | |
| 177 | + | |
| 178 | +const VERB_CALL = /\.\s*(get|post|put|delete|patch|head|options)\s*[<(]/gi; | |
| 179 | + | |
| 180 | +/** How well a method's body explains the request: verb first, then literals the URL carries. */ | |
| 181 | +function fit(body: string, request: SentRequest): {verb: boolean; matched: number; missed: number} { | |
| 182 | + const verbs = [...maskSource(body).matchAll(VERB_CALL)].map((m) => m[1].toUpperCase()); | |
| 183 | + const verb = !request.verb || verbs.length === 0 || verbs.includes(request.verb.toUpperCase()); | |
| 184 | + let matched = 0; | |
| 185 | + let missed = 0; | |
| 186 | + for (const m of body.matchAll(/'([^'\\\n]*)'|"([^"\\\n]*)"|`([^`$\\]*)`/g)) { | |
| 187 | + const literal = m[1] ?? m[2] ?? m[3] ?? ''; | |
| 188 | + if (literal.length < 2) continue; | |
| 189 | + if (request.url?.includes(literal)) matched++; | |
| 190 | + else missed++; | |
| 191 | + } | |
| 192 | + return {verb, matched, missed}; | |
| 193 | +} | |
| 194 | + | |
| 195 | +interface Resolved { | |
| 196 | + label: string; | |
| 197 | + matched: number; | |
| 198 | + missed: number; | |
| 199 | +} | |
| 200 | + | |
| 201 | +function resolveCandidates( | |
| 202 | + candidates: Candidate[], component: string, componentFile: string, frontendRoot: string, | |
| 203 | + readSource: ReadSource, request: SentRequest, | |
| 204 | +): string | undefined { | |
| 205 | + const masked = maskSource(component); | |
| 206 | + const resolved: Resolved[] = []; | |
| 207 | + const seen = new Set<string>(); | |
| 208 | + for (const {type: field, method} of candidates) { | |
| 209 | + const type = typeOfField(masked, field); | |
| 210 | + const file = type ? importedFile(component, type, componentFile) : undefined; | |
| 211 | + const source = file ? readSource(`${frontendRoot}/${file}`) : undefined; | |
| 212 | + const body = source ? bodyOf(source, method) : undefined; | |
| 213 | + if (!type || !body) continue; | |
| 214 | + const label = `${type}.${method}`; | |
| 215 | + if (seen.has(label)) continue; | |
| 216 | + seen.add(label); | |
| 217 | + const score = fit(body, request); | |
| 218 | + if (score.verb) resolved.push({label, matched: score.matched, missed: score.missed}); | |
| 219 | + } | |
| 220 | + if (resolved.length <= 1) return resolved[0]?.label; | |
| 221 | + resolved.sort((a, b) => b.matched - a.matched || a.missed - b.missed); | |
| 222 | + const [best, second] = resolved; | |
| 223 | + return best.matched !== second.matched || best.missed !== second.missed ? best.label : undefined; | |
| 224 | +} | |
| 225 | + | |
| 226 | +const NOT_A_METHOD = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'constructor']); | |
| 227 | +const MEMBER_DECL = /^[ \t]*(?:(?:public|private|protected|async|static|override)\s+)*([A-Za-z_$][\w$]*)\s*\([^;]*$/; | |
| 228 | + | |
| 229 | +/** `OwnerListComponent.searchByLastName`: the member the line is written in. */ | |
| 230 | +function enclosingMethod(component: string, line: number): string | undefined { | |
| 231 | + const lines = maskSource(component).split('\n'); | |
| 232 | + const cls = /\bclass\s+([A-Za-z_$][\w$]*)/.exec(component)?.[1]; | |
| 233 | + for (let i = Math.min(line, lines.length) - 1; i >= 0; i--) { | |
| 234 | + const m = MEMBER_DECL.exec(lines[i]); | |
| 235 | + if (!m || NOT_A_METHOD.has(m[1]) || !/\{\s*$/.test(lines[i])) continue; | |
| 236 | + return cls ? `${cls}.${m[1]}` : m[1]; | |
| 237 | + } | |
| 238 | + return undefined; | |
| 239 | +} | |
| 240 | + | |
| 241 | +/** | |
| 242 | + * The label of the call that one browser request was made through, or undefined when it has no call site. | |
| 243 | + * `frontendRoot` is the frontend module's directory; the frames are relative to it. | |
| 244 | + */ | |
| 245 | +export function clientMethodOf( | |
| 246 | + frames: CallFrame[], request: SentRequest, frontendRoot: string, readSource: ReadSource, | |
| 247 | +): string | undefined { | |
| 248 | + const trigger = triggerOf(frames); | |
| 249 | + if (!trigger) return undefined; | |
| 250 | + const fallback = `${trigger.file.split('/').pop()}:${trigger.line}`; | |
| 251 | + const component = readSource(`${frontendRoot}/${trigger.file}`); | |
| 252 | + if (component === undefined) return fallback; | |
| 253 | + const lineText = component.split('\n')[trigger.line - 1]; | |
| 254 | + // A line the checkout no longer has is not the line that ran: no reading into it. | |
| 255 | + if (lineText === undefined || (trigger.text !== undefined && lineText.trim() !== trigger.text)) { | |
| 256 | + return fallback; | |
| 257 | + } | |
| 258 | + const masked = maskSource(component); | |
| 259 | + const lineStart = offsetOfLine(component, trigger.line); | |
| 260 | + const subscribed = masked.slice(lineStart, lineStart + lineText.length).lastIndexOf('subscribe'); | |
| 261 | + const at = lineStart + (subscribed >= 0 ? subscribed : lineText.length); | |
| 262 | + const candidates = calledFrom(masked, at); | |
| 263 | + return resolveCandidates(candidates, component, trigger.file, frontendRoot, readSource, request) | |
| 264 | + ?? enclosingMethod(component, trigger.line) ?? fallback; | |
| 265 | +} |
| 1 | 1 | import {test, expect} from '@playwright/test'; |
| 2 | 2 | import { |
| 3 | - METHOD_LINK_MARKER, lineOfMethod, linkedMethodLabel, methodHandle, sourcePathOf, | |
| 3 | + METHOD_LINK_MARKER, lineOfMethod, linkedMethodLabel, methodHandle, overloadOf, sourcePathOf, | |
| 4 | 4 | } from './code-location'; |
| 5 | 5 | |
| 6 | 6 | const CONTROLLER = `package victor.training.petclinic.rest; |
| test('the marker rides inside the link, so the whole label is the target', () => | ||
| 102 | 102 | test('without a handle the label is exactly what it always was', () => { |
| 103 | 103 | expect(linkedMethodLabel('book-visit', undefined)).toBe('book-visit'); |
| 104 | 104 | }); |
| 105 | + | |
| 106 | +// petclinic-commons is linked into both services, so a span opened inside it carries the | |
| 107 | +// service that loaded it — and its class lives under neither service's folder. | |
| 108 | +test('a class from the shared library links into the library, whichever service ran it', () => { | |
| 109 | + const handle = methodHandle( | |
| 110 | + {'code.namespace': 'victor.training.commons.PhoneNumbers', 'code.function': 'normalize'}, | |
| 111 | + 'notification-service', '/repo', (p) => (p === | |
| 112 | + '/repo/petclinic-commons/src/main/java/victor/training/commons/PhoneNumbers.java' | |
| 113 | + ? 'class PhoneNumbers {\n static String normalize(String phone) {' : undefined)); | |
| 114 | + expect(handle).toBe('src://petclinic-commons/src/main/java/victor/training/commons/PhoneNumbers' | |
| 115 | + + '.java:2{Click to open PhoneNumbers.normalize}'); | |
| 116 | +}); | |
| 117 | + | |
| 118 | +// Two methods named `normalize`: the javadoc of the second even names the first, and the | |
| 119 | +// second calls the first. Each arrow has to open the overload it drew. | |
| 120 | +const PHONE_NUMBERS = `package victor.training.commons; | |
| 121 | + | |
| 122 | +public final class PhoneNumbers { | |
| 123 | + /** {@code +1 (608) 555-1023} → {@code +16085551023}. */ | |
| 124 | + public static String normalize(String phone) { | |
| 125 | + return phone; | |
| 126 | + } | |
| 127 | + | |
| 128 | + /** Like {@link #normalize(String)}, but a national number is given the caller's dial code. */ | |
| 129 | + public static String normalize(String phone, | |
| 130 | + HomeCountry homeCountry) { | |
| 131 | + String normalized = normalize(phone); | |
| 132 | + return homeCountry.homeDialCode() + normalized; | |
| 133 | + } | |
| 134 | +} | |
| 135 | +`; | |
| 136 | +const PHONE_NUMBERS_PATH = | |
| 137 | + '/repo/petclinic-commons/src/main/java/victor/training/commons/PhoneNumbers.java'; | |
| 138 | +const phoneHandle = (attributes: Record<string, string>) => methodHandle( | |
| 139 | + {'code.namespace': 'victor.training.commons.PhoneNumbers', 'code.function': 'normalize', | |
| 140 | + ...attributes}, | |
| 141 | + 'petclinic-backend', '/repo', (p) => (p === PHONE_NUMBERS_PATH ? PHONE_NUMBERS : undefined)); | |
| 142 | + | |
| 143 | +test('an overload is found by the parameter types the span was stamped with', () => { | |
| 144 | + expect(phoneHandle({'boundary.parameters': 'String, HomeCountry'})).toContain('PhoneNumbers.java:10{'); | |
| 145 | + expect(phoneHandle({'boundary.parameters': 'String'})).toContain('PhoneNumbers.java:5{'); | |
| 146 | +}); | |
| 147 | + | |
| 148 | +// A span recorded before the types were stamped still printed its arguments. | |
| 149 | +test('without stamped types, the number of arguments picks the overload', () => { | |
| 150 | + expect(phoneHandle({'boundary.args': '[8474461990, victor.training.X@64fb61b]'})) | |
| 151 | + .toContain('PhoneNumbers.java:10{'); | |
| 152 | + expect(phoneHandle({'boundary.args': '[+18474461990]'})).toContain('PhoneNumbers.java:5{'); | |
| 153 | +}); | |
| 154 | + | |
| 155 | +test('with nothing to tell overloads apart, the first one by name is the answer', () => { | |
| 156 | + expect(phoneHandle({})).toContain('PhoneNumbers.java:5{'); | |
| 157 | +}); | |
| 158 | + | |
| 159 | +test('a record or a list among the arguments counts as one', () => { | |
| 160 | + expect(overloadOf({'boundary.args': '[Owner[id=1, name=x], [a, b], 3]'})).toEqual({arity: 3}); | |
| 161 | + expect(overloadOf({'boundary.args': '[]'})).toEqual({arity: 0}); | |
| 162 | +}); | |
| 163 | + | |
| 164 | +test('parameter types are compared erased and unqualified, annotations ignored', () => { | |
| 165 | + const source = [ | |
| 166 | + 'class C {', | |
| 167 | + ' void put(int id) {}', | |
| 168 | + ' void put(@RequestBody final java.util.Map<String, List<Integer>> byId, String... rest) {}', | |
| 169 | + '}', | |
| 170 | + ].join('\n'); | |
| 171 | + expect(lineOfMethod(source, 'put', {types: ['Map', 'String[]']})).toBe(3); | |
| 172 | + expect(lineOfMethod(source, 'put', {types: ['int']})).toBe(2); | |
| 173 | +}); |
| export function sourcePathOf(serviceName: string, namespace: string): string | u | ||
| 50 | 50 | return `${serviceName}/src/main/java/${topLevel.split('.').join('/')}.java`; |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | +/** | |
| 54 | + * Jars linked into every service. A span opened inside one carries the service that loaded | |
| 55 | + * it, so its class is looked for there first and then here. | |
| 56 | + */ | |
| 57 | +export const LIBRARY_MODULES = ['petclinic-commons']; | |
| 58 | + | |
| 53 | 59 | /** A call, not a declaration: `x.foo(`, `y = foo(`, `return foo(`, `foo(bar(`. */ |
| 54 | 60 | const CALLED_NOT_DECLARED = /[.=(,]\s*$/; |
| 55 | 61 | |
| 62 | +/** | |
| 63 | + * Which of a method's overloads ran, as the span knows it. | |
| 64 | + * | |
| 65 | + * `types` is the real answer: the simple names of the declared parameter types, which | |
| 66 | + * petclinic-commons' boundary aspect stamps as `boundary.parameters` (`String, HomeCountry`). | |
| 67 | + * `arity` is the next best, counted from the arguments the same aspect printed — a span | |
| 68 | + * recorded before the types were stamped still tells `normalize(phone)` from | |
| 69 | + * `normalize(phone, country)` by how many values went in. | |
| 70 | + */ | |
| 71 | +export interface Overload { | |
| 72 | + types?: string[]; | |
| 73 | + arity?: number; | |
| 74 | +} | |
| 75 | + | |
| 56 | 76 | /** |
| 57 | 77 | * The 1-based line `fn` is declared on, or 0 when the source never declares it. |
| 58 | 78 | * |
| const CALLED_NOT_DECLARED = /[.=(,]\s*$/; | ||
| 60 | 80 | * Spring Data base interface declares, say — and the honest one: the class is still |
| 61 | 81 | * where the reader wants to land, and the line would be a guess. |
| 62 | 82 | * |
| 63 | - * The scan is deliberately as small as the one in test-location.ts. It takes the first | |
| 83 | + * The scan is deliberately as small as the one in test-location.ts. A declaration is a | |
| 64 | 84 | * line that mentions the name in a position a declaration can occupy, which rules out the |
| 65 | - * two shapes that actually occur above a declaration in this codebase: the method being | |
| 66 | - * called on a receiver, and the method being called to fill a variable or a return. It is | |
| 67 | - * not a Java parser and does not try to be one; a wrong line inside the right file is a | |
| 68 | - * cheap mistake, and no line at all is the fallback. | |
| 85 | + * shapes that actually occur above a declaration in this codebase: the method being | |
| 86 | + * called on a receiver, called to fill a variable or a return, or named in a comment | |
| 87 | + * (`{@link #normalize(String)}`). It is not a Java parser and does not try to be one. | |
| 88 | + * | |
| 89 | + * With overloads, the name alone lands on the first of them — `PhoneNumbers.normalize` | |
| 90 | + * once opened the 1-arg method for an arrow that had called the 2-arg one. So when the | |
| 91 | + * span says which overload ran, the declaration whose parameters match it wins; failing | |
| 92 | + * that, the first one by name. | |
| 69 | 93 | */ |
| 70 | -export function lineOfMethod(source: string, fn: string): number { | |
| 94 | +export function lineOfMethod(source: string, fn: string, overload: Overload = {}): number { | |
| 71 | 95 | const mention = new RegExp(`(^|[^\\w$.])${escapeRegExp(fn)}\\s*\\(`); |
| 72 | 96 | const lines = source.split(/\r?\n/); |
| 97 | + const declared: {line: number; types: string[]}[] = []; | |
| 73 | 98 | for (let i = 0; i < lines.length; i++) { |
| 74 | 99 | const at = lines[i].search(mention); |
| 75 | 100 | if (at < 0) continue; |
| 76 | 101 | const before = lines[i].slice(0, at + (lines[i][at] === fn[0] ? 0 : 1)); |
| 77 | 102 | if (CALLED_NOT_DECLARED.test(before)) continue; |
| 78 | 103 | if (/^\s*(?:return|throw)\b/.test(lines[i])) continue; |
| 79 | - return i + 1; | |
| 104 | + if (/^\s*(?:\/\/|\/\*|\*)/.test(lines[i])) continue; | |
| 105 | + declared.push({line: i + 1, types: parameterTypesFrom(lines, i, lines[i].indexOf('(', at))}); | |
| 80 | 106 | } |
| 81 | - return 0; | |
| 107 | + const {types, arity} = overload; | |
| 108 | + const exact = types && declared.find((d) => sameTypes(d.types, types)); | |
| 109 | + const counted = arity !== undefined ? declared.find((d) => d.types.length === arity) : undefined; | |
| 110 | + return (exact || counted || declared[0])?.line ?? 0; | |
| 82 | 111 | } |
| 83 | 112 | |
| 113 | +/** | |
| 114 | + * The parameter types of the declaration whose `(` is at `lines[row][column]`, as simple | |
| 115 | + * names: `(@RequestBody final Map<String, Integer> m, String... rest)` → `Map`, `String[]`. | |
| 116 | + * Erased and unqualified, which is what `Class.getSimpleName()` gives on the other side. | |
| 117 | + */ | |
| 118 | +export function parameterTypesFrom(lines: string[], row: number, column: number): string[] { | |
| 119 | + let text = ''; | |
| 120 | + let depth = 0; | |
| 121 | + for (let i = row; i < lines.length; i++) { | |
| 122 | + for (let j = i === row ? column : 0; j < lines[i].length; j++) { | |
| 123 | + const ch = lines[i][j]; | |
| 124 | + if (ch === '(') depth++; | |
| 125 | + else if (ch === ')' && --depth === 0) return splitParameters(text.slice(1)); | |
| 126 | + text += ch; | |
| 127 | + } | |
| 128 | + text += ' '; | |
| 129 | + } | |
| 130 | + return []; | |
| 131 | +} | |
| 132 | + | |
| 133 | +function splitParameters(list: string): string[] { | |
| 134 | + const cleaned = list.replace(/@[\w.]+(\s*\([^)]*\))?/g, ' '); | |
| 135 | + const params: string[] = []; | |
| 136 | + let depth = 0; | |
| 137 | + let current = ''; | |
| 138 | + for (const ch of cleaned) { | |
| 139 | + if (ch === '<') depth++; | |
| 140 | + if (ch === '>') depth--; | |
| 141 | + if (ch === ',' && depth === 0) { | |
| 142 | + params.push(current); | |
| 143 | + current = ''; | |
| 144 | + } else { | |
| 145 | + current += ch; | |
| 146 | + } | |
| 147 | + } | |
| 148 | + params.push(current); | |
| 149 | + return params.map(simpleTypeOf).filter((t) => t !== ''); | |
| 150 | +} | |
| 151 | + | |
| 152 | +/** `final java.util.Map<K, V> byId` → `Map`; `String... rest` → `String[]`. */ | |
| 153 | +function simpleTypeOf(parameter: string): string { | |
| 154 | + let generic = parameter; | |
| 155 | + while (/<[^<>]*>/.test(generic)) generic = generic.replace(/<[^<>]*>/g, ''); | |
| 156 | + const words = generic.replace(/\bfinal\b/g, ' ').replace(/\.\.\./g, '[] ') | |
| 157 | + .replace(/\s*\[\s*\]/g, '[]').trim().split(/\s+/); | |
| 158 | + if (words.length < 2) return ''; | |
| 159 | + const type = words.slice(0, -1).join(''); | |
| 160 | + return type.slice(type.lastIndexOf('.') + 1); | |
| 161 | +} | |
| 162 | + | |
| 163 | +function sameTypes(declared: string[], ran: string[]): boolean { | |
| 164 | + return declared.length === ran.length && declared.every((t, i) => t === ran[i]); | |
| 165 | +} | |
| 166 | + | |
| 167 | +/** | |
| 168 | + * The overload a span names: its stamped parameter types when it has them, else the | |
| 169 | + * number of arguments it printed. `[8474461990, a.B@64fb61b]` is two; brackets nest, so a | |
| 170 | + * record's `Owner[id=1, name=x]` is one. | |
| 171 | + */ | |
| 172 | +export function overloadOf(attributes: Record<string, string>): Overload { | |
| 173 | + const stamped = attributes[PARAMETERS_ATTRIBUTE]; | |
| 174 | + if (stamped !== undefined) { | |
| 175 | + return {types: stamped.split(',').map((t) => t.trim()).filter((t) => t !== '')}; | |
| 176 | + } | |
| 177 | + const printed = attributes['boundary.args']; | |
| 178 | + if (printed === undefined || !/^\[.*\]$/s.test(printed)) return {}; | |
| 179 | + const inner = printed.slice(1, -1); | |
| 180 | + if (inner.trim() === '') return {arity: 0}; | |
| 181 | + let depth = 0; | |
| 182 | + let arity = 1; | |
| 183 | + for (const ch of inner) { | |
| 184 | + if (ch === '[' || ch === '(' || ch === '{') depth++; | |
| 185 | + else if (ch === ']' || ch === ')' || ch === '}') depth--; | |
| 186 | + else if (ch === ',' && depth === 0) arity++; | |
| 187 | + } | |
| 188 | + return {arity}; | |
| 189 | +} | |
| 190 | + | |
| 191 | +/** Stamped by petclinic-commons' ModuleBoundaryTracing: `String, HomeCountry`. */ | |
| 192 | +export const PARAMETERS_ATTRIBUTE = 'boundary.parameters'; | |
| 193 | + | |
| 84 | 194 | function escapeRegExp(s: string): string { |
| 85 | 195 | return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| 86 | 196 | } |
| export function methodHandle( | ||
| 104 | 214 | const namespace = attributes['code.namespace']; |
| 105 | 215 | const fn = attributes['code.function']; |
| 106 | 216 | if (!namespace || !fn) return undefined; |
| 107 | - const relative = sourcePathOf(serviceName, namespace); | |
| 108 | - if (!relative) return undefined; | |
| 109 | - const text = readSource(`${repoRoot}/${relative}`); | |
| 110 | - if (text === undefined) return undefined; | |
| 111 | - const line = lineOfMethod(text, fn); | |
| 112 | - return `src://${relative}${line > 0 ? `:${line}` : ''}${methodLinkTooltip(namespace, fn)}`; | |
| 217 | + for (const module of [serviceName, ...LIBRARY_MODULES]) { | |
| 218 | + const relative = sourcePathOf(module, namespace); | |
| 219 | + const text = relative ? readSource(`${repoRoot}/${relative}`) : undefined; | |
| 220 | + if (text === undefined) continue; | |
| 221 | + const line = lineOfMethod(text, fn, overloadOf(attributes)); | |
| 222 | + return `src://${relative}${line > 0 ? `:${line}` : ''}${methodLinkTooltip(namespace, fn)}`; | |
| 223 | + } | |
| 224 | + return undefined; | |
| 113 | 225 | } |
| 114 | 226 | |
| 115 | 227 | /** |
| 1 | +import {expect, test} from '@playwright/test'; | |
| 2 | +import type {NormSpan} from './trace-to-puml'; | |
| 3 | +import {handlerHandle, handlerOf, moduleHandlers, routeOf} from './controller-location'; | |
| 4 | + | |
| 5 | +// A request's arrow opens the controller method it landed on, resolved by its route against | |
| 6 | +// the service's sources: the trace carries the route and no code. | |
| 7 | + | |
| 8 | +const OWNERS = `package victor.training.petclinic.rest; | |
| 9 | + | |
| 10 | +@RestController | |
| 11 | +@RequestMapping("/api/owners") | |
| 12 | +@PreAuthorize("hasRole(@roles.OWNER_ADMIN)") | |
| 13 | +public class OwnerRestController { | |
| 14 | + | |
| 15 | + @Operation(operationId = "listOwners", summary = "List owners") | |
| 16 | + @ApiResponse(responseCode = "200", description = "OK", | |
| 17 | + content = @Content(mediaType = "application/json", | |
| 18 | + array = @ArraySchema(schema = @Schema(implementation = OwnerDto.class)))) | |
| 19 | + @GetMapping(produces = "application/json") | |
| 20 | + public List<OwnerDto> listOwners(@RequestParam String lastName) { | |
| 21 | + return null; | |
| 22 | + } | |
| 23 | + | |
| 24 | + @GetMapping("/{ownerId}") | |
| 25 | + public OwnerDto getOwner(@PathVariable int ownerId) { | |
| 26 | + return null; | |
| 27 | + } | |
| 28 | + | |
| 29 | + @PostMapping("{ownerId}/pets/{petId}/visits") | |
| 30 | + public ResponseEntity<VisitDto> addVisitToOwner(@PathVariable int ownerId) { | |
| 31 | + return null; | |
| 32 | + } | |
| 33 | + | |
| 34 | + @RequestMapping(value = "/legacy", method = {RequestMethod.PUT, RequestMethod.PATCH}) | |
| 35 | + public void legacy() { | |
| 36 | + } | |
| 37 | +} | |
| 38 | +`; | |
| 39 | + | |
| 40 | +const NOTIFICATIONS = `package victor.training.notification; | |
| 41 | + | |
| 42 | +@RestController | |
| 43 | +public class NotificationController { | |
| 44 | + @PostMapping(VisitBookedNotification.PATH) | |
| 45 | + public void visitBooked(@RequestBody VisitBookedNotification n) { | |
| 46 | + } | |
| 47 | +} | |
| 48 | +`; | |
| 49 | + | |
| 50 | +// The shared constant lives in a library linked into both the service and its client. | |
| 51 | +const SHARED = `package victor.training.commons; | |
| 52 | +public record VisitBookedNotification(String phone) { | |
| 53 | + public static final String PATH = "/api/notifications/visit-booked"; | |
| 54 | +} | |
| 55 | +`; | |
| 56 | + | |
| 57 | +const REPO = '/repo'; | |
| 58 | +const JAVA = (module: string) => `${REPO}/${module}/src/main/java/victor/training`; | |
| 59 | +const files: Record<string, string> = { | |
| 60 | + [`${JAVA('petclinic-backend')}/petclinic/rest/OwnerRestController.java`]: OWNERS, | |
| 61 | + [`${JAVA('notification-service')}/notification/NotificationController.java`]: NOTIFICATIONS, | |
| 62 | + [`${JAVA('petclinic-commons')}/commons/VisitBookedNotification.java`]: SHARED, | |
| 63 | +}; | |
| 64 | +const read = (p: string) => files[p]; | |
| 65 | +const walk = (dir: string) => Object.keys(files).filter((f) => f.startsWith(`${dir}/`)); | |
| 66 | +const lineOf = (source: string, needle: string) => | |
| 67 | + source.split('\n').findIndex((l) => l.includes(needle)) + 1; | |
| 68 | + | |
| 69 | +test('a method mapping joins its class’s base path, past every annotation between them', () => { | |
| 70 | + const handlers = moduleHandlers('petclinic-backend', REPO, read, walk); | |
| 71 | + const file = 'petclinic-backend/src/main/java/victor/training/petclinic/rest/OwnerRestController.java'; | |
| 72 | + expect(handlers.get('GET /api/owners')).toEqual( | |
| 73 | + {file, line: lineOf(OWNERS, 'listOwners(@'), name: 'OwnerRestController.listOwners'}); | |
| 74 | + expect(handlers.get('GET /api/owners/{ownerId}')?.name).toBe('OwnerRestController.getOwner'); | |
| 75 | + expect(handlers.get('POST /api/owners/{ownerId}/pets/{petId}/visits')?.line) | |
| 76 | + .toBe(lineOf(OWNERS, 'addVisitToOwner(')); | |
| 77 | + expect(handlers.get('PUT /api/owners/legacy')?.name).toBe('OwnerRestController.legacy'); | |
| 78 | + expect(handlers.get('PATCH /api/owners/legacy')?.name).toBe('OwnerRestController.legacy'); | |
| 79 | +}); | |
| 80 | + | |
| 81 | +test('a route named by a constant in a shared library resolves', () => { | |
| 82 | + const handlers = moduleHandlers('notification-service', REPO, read, walk); | |
| 83 | + expect(handlers.get('POST /api/notifications/visit-booked')?.name) | |
| 84 | + .toBe('NotificationController.visitBooked'); | |
| 85 | +}); | |
| 86 | + | |
| 87 | +const server = (attributes: Record<string, string>, name = 'GET /api/owners/{ownerId}'): NormSpan => ({ | |
| 88 | + traceId: 't', spanId: 's', parentSpanId: 'c', name, kind: 'SERVER', | |
| 89 | + serviceName: 'petclinic-backend', startNano: 0, attributes, | |
| 90 | +}); | |
| 91 | + | |
| 92 | +test('the route comes from the span’s attributes, else from its name', () => { | |
| 93 | + expect(routeOf(server({'http.request.method': 'GET', 'http.route': '/api/owners/{ownerId}'}))) | |
| 94 | + .toBe('GET /api/owners/{ownerId}'); | |
| 95 | + expect(routeOf(server({}))).toBe('GET /api/owners/{ownerId}'); | |
| 96 | + expect(routeOf(server({}, 'GET'))).toBeUndefined(); | |
| 97 | +}); | |
| 98 | + | |
| 99 | +test('the handle opens the declaration and says which method it is', () => { | |
| 100 | + const handlers = moduleHandlers('petclinic-backend', REPO, read, walk); | |
| 101 | + expect(handlerHandle(server({}), handlers)).toBe( | |
| 102 | + 'src://petclinic-backend/src/main/java/victor/training/petclinic/rest/OwnerRestController.java:' | |
| 103 | + + `${lineOf(OWNERS, 'getOwner(@')}{Click to open OwnerRestController.getOwner}`); | |
| 104 | + expect(handlerHandle(server({}, 'GET /api/nowhere'), handlers)).toBeUndefined(); | |
| 105 | + expect(handlerHandle({...server({}), kind: 'CLIENT'}, handlers)).toBeUndefined(); | |
| 106 | +}); | |
| 107 | + | |
| 108 | +// The arrow names the controller as well as linking it: a third line, or a self-call. | |
| 109 | +test('the handler is named as the diagram says it, beside its handle', () => { | |
| 110 | + const handlers = moduleHandlers('petclinic-backend', REPO, read, walk); | |
| 111 | + expect(handlerOf(server({}), handlers)).toEqual({ | |
| 112 | + name: 'OwnerRestController.getOwner', handle: handlerHandle(server({}), handlers), | |
| 113 | + }); | |
| 114 | + expect(handlerOf(server({}, 'GET /api/nowhere'), handlers)).toBeUndefined(); | |
| 115 | +}); |
| 1 | +import type {NormSpan} from './trace-to-puml'; | |
| 2 | +import type {ReadSource} from './code-location'; | |
| 3 | +import {LIBRARY_MODULES, methodLinkTooltip} from './code-location'; | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * The method a request lands on, for the arrow that carries it. | |
| 7 | + * | |
| 8 | + * A SERVER span knows its route (`GET /api/owners/{ownerId}`) and nothing about the code | |
| 9 | + * that answered it: the agent opens no span for a controller method. So the route is | |
| 10 | + * resolved against the service's own sources, the way Spring resolves it — the class-level | |
| 11 | + * `@RequestMapping` joined to the method's `@GetMapping` — and the arrow's words open the | |
| 12 | + * first class of the application the request reaches (Victor, 10 Nov 2026). | |
| 13 | + * | |
| 14 | + * A line scan, not a Java parser: the three things it needs — a class's base path, a | |
| 15 | + * method's mapping annotation, and the declaration under it — are each one line of Java, | |
| 16 | + * and a mapping annotation appears nowhere else. The same scan the review page runs for its | |
| 17 | + * detail panel's head (human-review's `spring_handlers`), so the two never disagree. | |
| 18 | + */ | |
| 19 | + | |
| 20 | +/** Lists every file under a directory, recursively, as absolute paths. */ | |
| 21 | +export type WalkFiles = (dir: string) => string[]; | |
| 22 | + | |
| 23 | +export interface Handler { | |
| 24 | + /** Repo-relative: `petclinic-backend/src/main/java/…/OwnerRestController.java`. */ | |
| 25 | + file: string; | |
| 26 | + line: number; | |
| 27 | + /** `OwnerRestController.getOwner`. */ | |
| 28 | + name: string; | |
| 29 | +} | |
| 30 | + | |
| 31 | +const HTTP_VERBS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH']; | |
| 32 | +const MAPPING = /^@(Get|Post|Put|Delete|Patch|Request)Mapping\b/; | |
| 33 | +const REQUEST_METHOD = /RequestMethod\.(GET|POST|PUT|DELETE|PATCH)\b/g; | |
| 34 | +const NAMED_PATH = /\b(?:value|path)\s*=\s*\{?\s*"([^"]*)"/; | |
| 35 | +const POSITIONAL_PATH = /^\s*\{?\s*"([^"]*)"/; | |
| 36 | +const NAMED_CONSTANT = /\b(?:value|path)\s*=\s*\{?\s*([A-Za-z_][\w.]*)/; | |
| 37 | +const POSITIONAL_CONSTANT = /^\s*\{?\s*([A-Za-z_][\w.]*)\s*(?:[,}]|$)/; | |
| 38 | +const TYPE_DECL = /\b(?:class|interface|record)\s+([A-Za-z_]\w*)/; | |
| 39 | +const METHOD_NAME = /([A-Za-z_]\w*)\s*\(/; | |
| 40 | +const STRING_CONSTANT = /static\s+final\s+String\s+([A-Za-z_]\w*)\s*=\s*"([^"]*)"/g; | |
| 41 | + | |
| 42 | +/** The parenthesised text of the annotation that starts on `lines[row]`, and its last row. */ | |
| 43 | +function annotationArgs(lines: string[], row: number): {args: string; end: number} { | |
| 44 | + let text = ''; | |
| 45 | + let depth = 0; | |
| 46 | + let started = false; | |
| 47 | + for (let j = row; j < lines.length; j++) { | |
| 48 | + for (const ch of lines[j]) { | |
| 49 | + if (ch === '(') { | |
| 50 | + depth++; | |
| 51 | + started = true; | |
| 52 | + if (depth === 1) continue; | |
| 53 | + } else if (ch === ')' && --depth === 0) { | |
| 54 | + return {args: text, end: j}; | |
| 55 | + } | |
| 56 | + if (started) text += ch; | |
| 57 | + } | |
| 58 | + // A bare `@GetMapping` ends on its own line; one whose `(` wraps goes on. | |
| 59 | + if (!started) return {args: '', end: j}; | |
| 60 | + text += ' '; | |
| 61 | + } | |
| 62 | + return {args: text, end: lines.length - 1}; | |
| 63 | +} | |
| 64 | + | |
| 65 | +function mappingPath(args: string, constants: Map<string, string>): string { | |
| 66 | + const literal = NAMED_PATH.exec(args) ?? POSITIONAL_PATH.exec(args); | |
| 67 | + if (literal) return literal[1]; | |
| 68 | + // `@PostMapping(VisitBookedNotification.PATH)`: a route the service and its client share. | |
| 69 | + const ref = NAMED_CONSTANT.exec(args) ?? POSITIONAL_CONSTANT.exec(args); | |
| 70 | + if (!ref) return ''; | |
| 71 | + return constants.get(ref[1]) ?? constants.get(ref[1].split('.').pop()!) ?? ''; | |
| 72 | +} | |
| 73 | + | |
| 74 | +/** `{ownerId:\d+}` is `{ownerId}` on the span: Spring reports the template, not the regex. */ | |
| 75 | +function normalizeRoute(route: string): string { | |
| 76 | + const parts = route.replace(/\{([^}:]+):[^}]*\}/g, '{$1}').split('/').filter((p) => p !== ''); | |
| 77 | + return `/${parts.join('/')}`; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** Every `VERB /route` one controller source handles, into `found`. */ | |
| 81 | +export function controllerRoutes( | |
| 82 | + source: string, file: string, constants: Map<string, string>, found: Map<string, Handler>, | |
| 83 | +): void { | |
| 84 | + const lines = source.split(/\r?\n/); | |
| 85 | + let base = ''; | |
| 86 | + let cls = file.split('/').pop()!.replace(/\.java$/, ''); | |
| 87 | + let pending: {verbs: string[]; path: string} | undefined; | |
| 88 | + for (let i = 0; i < lines.length; i++) { | |
| 89 | + const stripped = lines[i].trim(); | |
| 90 | + if (!stripped || /^(?:\/\/|\/\*|\*)/.test(stripped)) continue; | |
| 91 | + if (stripped.startsWith('@')) { | |
| 92 | + const {args, end} = annotationArgs(lines, i); | |
| 93 | + const mapping = MAPPING.exec(stripped); | |
| 94 | + if (mapping) { | |
| 95 | + const verbs = mapping[1] !== 'Request' ? [mapping[1].toUpperCase()] | |
| 96 | + : [...args.matchAll(REQUEST_METHOD)].map((m) => m[1]); | |
| 97 | + pending = {verbs: verbs.length > 0 ? verbs : HTTP_VERBS, path: mappingPath(args, constants)}; | |
| 98 | + } | |
| 99 | + // Annotations pile up until a declaration consumes them: `@Operation`, `@PreAuthorize` | |
| 100 | + // and a four-line `@ApiResponse` between a mapping and its method cost nothing. | |
| 101 | + i = end; | |
| 102 | + continue; | |
| 103 | + } | |
| 104 | + const declared = TYPE_DECL.exec(stripped); | |
| 105 | + if (declared) { | |
| 106 | + cls = declared[1]; | |
| 107 | + base = pending?.path ?? ''; | |
| 108 | + pending = undefined; | |
| 109 | + continue; | |
| 110 | + } | |
| 111 | + const name = pending ? METHOD_NAME.exec(stripped) : null; | |
| 112 | + if (name && pending) record(found, pending, base, file, i + 1, `${cls}.${name[1]}`); | |
| 113 | + pending = undefined; | |
| 114 | + } | |
| 115 | +} | |
| 116 | + | |
| 117 | +function record( | |
| 118 | + found: Map<string, Handler>, pending: {verbs: string[]; path: string}, base: string, | |
| 119 | + file: string, line: number, name: string, | |
| 120 | +): void { | |
| 121 | + for (const verb of pending.verbs) { | |
| 122 | + const key = `${verb} ${normalizeRoute(`${base}/${pending.path}`)}`; | |
| 123 | + if (!found.has(key)) found.set(key, {file, line, name}); | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +/** `VERB /route` → the method that handles it, for every controller of one module. */ | |
| 128 | +export function moduleHandlers( | |
| 129 | + module: string, repoRoot: string, readSource: ReadSource, walk: WalkFiles, | |
| 130 | +): Map<string, Handler> { | |
| 131 | + const constants = new Map<string, string>(); | |
| 132 | + const controllers: Array<{file: string; text: string}> = []; | |
| 133 | + // The jars linked into every service are read for their constants too: a route a service | |
| 134 | + // and its client share (`VisitBookedNotification.PATH`) is declared in petclinic-commons. | |
| 135 | + const sources = [module, ...LIBRARY_MODULES.filter((m) => m !== module)].flatMap((m) => | |
| 136 | + walk(`${repoRoot}/${m}/src/main/java`).filter((f) => f.endsWith('.java')).sort() | |
| 137 | + .map((absolute) => ({absolute, own: m === module}))); | |
| 138 | + for (const {absolute, own} of sources) { | |
| 139 | + const text = readSource(absolute); | |
| 140 | + if (text === undefined) continue; | |
| 141 | + const file = absolute.slice(repoRoot.length + 1); | |
| 142 | + if (text.includes('static final String')) { | |
| 143 | + const cls = file.split('/').pop()!.replace(/\.java$/, ''); | |
| 144 | + for (const m of text.matchAll(STRING_CONSTANT)) { | |
| 145 | + constants.set(`${cls}.${m[1]}`, m[2]); | |
| 146 | + if (!constants.has(m[1])) constants.set(m[1], m[2]); | |
| 147 | + } | |
| 148 | + } | |
| 149 | + if (own && /@(?:Rest)?Controller\b/.test(text)) controllers.push({file, text}); | |
| 150 | + } | |
| 151 | + // Second, so a mapping may name a constant declared in a file the walk reached later. | |
| 152 | + const found = new Map<string, Handler>(); | |
| 153 | + for (const {file, text} of controllers) controllerRoutes(text, file, constants, found); | |
| 154 | + return found; | |
| 155 | +} | |
| 156 | + | |
| 157 | +/** `GET /api/owners/{ownerId}`, from the span's own attributes before its name. */ | |
| 158 | +export function routeOf(span: NormSpan): string | undefined { | |
| 159 | + const named = /^([A-Z]+)\s+(\/\S*)$/.exec(span.name.trim()); | |
| 160 | + const verb = span.attributes['http.request.method'] ?? span.attributes['http.method'] ?? named?.[1]; | |
| 161 | + const route = span.attributes['http.route'] ?? named?.[2]; | |
| 162 | + return verb && route ? `${verb.toUpperCase()} ${normalizeRoute(route)}` : undefined; | |
| 163 | +} | |
| 164 | + | |
| 165 | +/** The method a SERVER span's request landed on — its name and its `src://` handle — or undefined. */ | |
| 166 | +export function handlerOf( | |
| 167 | + span: NormSpan, handlers: Map<string, Handler>, | |
| 168 | +): {name: string; handle: string} | undefined { | |
| 169 | + const route = span.kind === 'SERVER' ? routeOf(span) : undefined; | |
| 170 | + const hit = route ? handlers.get(route) : undefined; | |
| 171 | + if (!hit) return undefined; | |
| 172 | + const [cls, fn] = [hit.name.slice(0, hit.name.lastIndexOf('.')), hit.name.split('.').pop()!]; | |
| 173 | + return {name: hit.name, handle: `src://${hit.file}:${hit.line}${methodLinkTooltip(cls, fn)}`}; | |
| 174 | +} | |
| 175 | + | |
| 176 | +/** The `src://` handle on the method a SERVER span's request landed on, or undefined. */ | |
| 177 | +export function handlerHandle(span: NormSpan, handlers: Map<string, Handler>): string | undefined { | |
| 178 | + return handlerOf(span, handlers)?.handle; | |
| 179 | +} |
| test('an id is plain lowercase alphanumerics', () => { | ||
| 31 | 31 | const collector = new DetailCollector(); |
| 32 | 32 | expect(collector.add(entry('select 1'))).toMatch(/^[a-z0-9]{7,}$/); |
| 33 | 33 | }); |
| 34 | + | |
| 35 | +// Version 3: a request arrow's entry carries the request, for the review page to replay. | |
| 36 | +test('an entry carries the request it was sent with, through the index', () => { | |
| 37 | + const collector = new DetailCollector(); | |
| 38 | + const request = {method: 'POST', url: 'http://h/api/visits', headers: {Accept: '*/*'}, body: '{"a":1}'}; | |
| 39 | + const id = collector.add({...entry('POST http://h/api/visits'), request}); | |
| 40 | + expect(DETAIL_INDEX_VERSION).toBe(3); | |
| 41 | + expect(collector.toIndex().details[id].request).toEqual(request); | |
| 42 | +}); |
| export interface DetailStep { | ||
| 15 | 15 | alternate?: DetailStep; |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | +/** | |
| 19 | + * An HTTP request as it was sent, for a panel to offer to send again. The steps already say it | |
| 20 | + * to a reader; this says it to a tool — the review page's "Open in Bruno" replays the call from | |
| 21 | + * it (Victor, 10 Nov 2026), so nothing here is a template: `url` is the absolute URL with the | |
| 22 | + * real ids in it, and `body` the payload exactly as it went out, not pretty-printed. | |
| 23 | + */ | |
| 24 | +export interface HttpRequest { | |
| 25 | + method: string; | |
| 26 | + url: string; | |
| 27 | + headers?: Record<string, string>; | |
| 28 | + body?: string; | |
| 29 | +} | |
| 30 | + | |
| 18 | 31 | /** Everything one arrow can reveal, in the order the clicks reveal it. */ |
| 19 | 32 | export interface DetailEntry { |
| 20 | 33 | title: string; |
| 21 | 34 | steps: DetailStep[]; |
| 35 | + /** On an HTTP request arrow: the request itself, in a shape a client can replay. */ | |
| 36 | + request?: HttpRequest; | |
| 22 | 37 | } |
| 23 | 38 | |
| 24 | 39 | export interface DetailIndex { |
| export interface DetailIndex { | ||
| 26 | 41 | details: Record<string, DetailEntry>; |
| 27 | 42 | } |
| 28 | 43 | |
| 29 | -export const DETAIL_INDEX_VERSION = 2; // 2: a step can carry an `alternate` to toggle to | |
| 44 | +// 2: a step can carry an `alternate` to toggle to. | |
| 45 | +// 3: an HTTP request arrow's entry carries the `request` it sent, for the page to replay. | |
| 46 | +export const DETAIL_INDEX_VERSION = 3; | |
| 30 | 47 | |
| 31 | 48 | // FNV-1a. The id has to depend on the arrow's *content* alone: a counter would |
| 32 | 49 | // renumber every arrow below an inserted one, and the review page renders a |
| 1 | 1 | import {test, expect} from '@playwright/test'; |
| 2 | 2 | import {parseTempoTrace} from './trace-to-puml'; |
| 3 | 3 | import {DETAIL_INDEX_VERSION} from './detail-index'; |
| 4 | +import {DEFAULT_DIAGRAM_OPTIONS} from './options'; | |
| 4 | 5 | import * as fs from 'fs'; |
| 5 | 6 | import * as path from 'path'; |
| 6 | 7 | import {CachedSource, GenerateDeps, JAVA_SOURCES, TestWindow, detailsPathFor, diagramPathFor, generateFromWindows, mergeCachedSources, ownedSourcesFromEnv, renderScenarios, slugify, spanCachePathFor, uniqueSlugs} from './generate'; |
| test('what the markers reveal is filed beside the diagram, and is not itself a d | ||
| 59 | 60 | [{source: 'src/add-visit.spec.ts', |
| 60 | 61 | scenarios: [{title: 'Add a visit', traces: [parseTempoTrace(fixture)]}]}], |
| 61 | 62 | '/out', {writeFile: (p, c) => { written[p] = c; }, log: () => {}}, |
| 62 | - {sql: 'statement', httpBodies: true, interactive: true}, | |
| 63 | + {sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'}, | |
| 63 | 64 | ); |
| 64 | 65 | |
| 65 | 66 | expect(detailsPathFor('/out', 'src/add-visit.spec.ts', 'add-a-visit')) |
| test('the static path writes the picture alone, and drops a sidecar left by an e | ||
| 83 | 84 | scenarios: [{title: 'Add a visit', traces: [parseTempoTrace(fixture)]}]}], |
| 84 | 85 | '/out', |
| 85 | 86 | {writeFile: (p, c) => { written[p] = c; }, removeFile: (p) => removed.push(p), log: () => {}}, |
| 86 | - {sql: 'values', httpBodies: true, interactive: false}, | |
| 87 | + {sql: 'values', httpBodies: true, interactive: false, handler: 'panel'}, | |
| 87 | 88 | ); |
| 88 | 89 | expect(Object.keys(written)).toEqual(['/out/generated/add-visit.spec.ts.add-a-visit.genseq.puml']); |
| 89 | 90 | expect(removed).toEqual(['/out/generated/add-visit.spec.ts.add-a-visit.genseq.json']); |
| test('a diagram no scenario draws any more is swept', () => { | ||
| 110 | 111 | ], |
| 111 | 112 | log: () => {}, |
| 112 | 113 | }, |
| 113 | - {sql: 'statement', httpBodies: true, interactive: true}, | |
| 114 | + {sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'}, | |
| 114 | 115 | ); |
| 115 | 116 | expect(removed).toEqual([ |
| 116 | 117 | '/out/generated/add-visit.spec.ts.cancel-a-visit.genseq.puml', |
| test('generateFromWindows writes one puml per scenario', async () => { | ||
| 141 | 142 | const addVisit = written['/out/generated/add-visit.spec.ts.add-a-visit.genseq.puml']; |
| 142 | 143 | // The scenario names the picture; the file it lives in is in the footer, and the |
| 143 | 144 | // divider that used to separate chapters is gone with the chapters. |
| 144 | - expect(addVisit).toContain('title Add a visit'); | |
| 145 | + expect(addVisit).toContain('title Test: Add a visit'); | |
| 145 | 146 | expect(addVisit).not.toContain('== '); |
| 146 | 147 | // …and the other scenario is a picture of its own, not a section further down this one |
| 147 | 148 | expect(addVisit).not.toContain('Cancel a visit'); |
| 148 | 149 | expect(written['/out/generated/add-visit.spec.ts.cancel-a-visit.genseq.puml']) |
| 149 | - .toContain('title Cancel a visit'); | |
| 150 | + .toContain('title Test: Cancel a visit'); | |
| 150 | 151 | // the arrow is wrapped in its reveal link now, so match the label inside it |
| 151 | 152 | expect(addVisit).toContain('addVisit\\nPOST /api/visits'); |
| 152 | 153 | expect(addVisit).toContain("' ⚠️ GENERATED FILE — DO NOT EDIT"); |
| test('each section header links to the test that drew it', () => { | ||
| 167 | 168 | }, |
| 168 | 169 | ); |
| 169 | 170 | expect(written['/out/petclinic-test/generated/add-visit.spec.ts.add-a-visit.genseq.puml']).toContain( |
| 170 | - 'title [[src://petclinic-test/src/add-visit.spec.ts:1{Click to open the test} Add a visit]]'); | |
| 171 | + 'title [[src://petclinic-test/src/add-visit.spec.ts:1{Click to open the test} Test: Add a visit]]'); | |
| 171 | 172 | }); |
| 172 | 173 | |
| 173 | 174 | // Nothing to read the source with means nothing to point at: a header that links into a |
| test('a header stays plain when the test source cannot be read', () => { | ||
| 180 | 181 | '/out', {writeFile: (p, c) => { written[p] = c; }, log: () => {}}, |
| 181 | 182 | ); |
| 182 | 183 | expect(written['/out/generated/add-visit.spec.ts.add-a-visit.genseq.puml']) |
| 183 | - .toContain('title Add a visit'); | |
| 184 | + .toContain('title Test: Add a visit'); | |
| 184 | 185 | }); |
| 185 | 186 | |
| 186 | 187 | test('generateFromWindows skips (no throw) when a test has zero traces', async () => { |
| test('renderScenarios redraws from the cache at another detail level, touching n | ||
| 325 | 326 | }])); |
| 326 | 327 | |
| 327 | 328 | renderScenarios(cached, '/out', {writeFile: (p, c) => { written[p] = c; }, log: () => {}}, |
| 328 | - {sql: 'off', httpBodies: false, interactive: false}); | |
| 329 | + {sql: 'off', httpBodies: false, interactive: false, handler: 'panel'}); | |
| 329 | 330 | |
| 330 | 331 | const puml = written['/out/generated/add-visit.spec.ts.add-a-visit.genseq.puml']; |
| 331 | - expect(puml).toContain('title Add a visit'); | |
| 332 | + expect(puml).toContain('title Test: Add a visit'); | |
| 332 | 333 | expect(puml).not.toContain('SELECT'); |
| 333 | 334 | }); |
| 334 | 335 | |
| test('a petclinic-test source keeps its module-relative title', () => { | ||
| 397 | 398 | ); |
| 398 | 399 | expect(Object.values(written)[0]).toContain('in src/add-visit.spec.ts —'); |
| 399 | 400 | }); |
| 401 | + | |
| 402 | +// The whole join: the request's frames reach the arrow through the span, and through the span | |
| 403 | +// cache too, so a replay with nothing running keeps every link. | |
| 404 | +test('a recorded call site reaches the arrow, and survives in the span cache', async () => { | |
| 405 | + const written: Record<string, string> = {}; | |
| 406 | + const source = 'this.visitService.addVisit(visit).subscribe('; | |
| 407 | + const file = 'src/app/visits/visit-add/visit-add.component.ts'; | |
| 408 | + const logged: string[] = []; | |
| 409 | + const spans = JSON.parse(JSON.stringify(fixture)); | |
| 410 | + const client = spans.batches[0].scopeSpans[0].spans[1]; | |
| 411 | + client.traceId = 'fE7B4usvB9KFqEyldpZ53Q=='; | |
| 412 | + client.spanId = 'lrlBhfFN7/8='; | |
| 413 | + spans.batches[1].scopeSpans[0].spans[0].parentSpanId = 'lrlBhfFN7/8='; | |
| 414 | + await generateFromWindows( | |
| 415 | + [{title: 'Add a visit', source: 'src/add-visit.spec.ts', startMs: 0, endMs: 10_000}], | |
| 416 | + '/repo/petclinic-test', | |
| 417 | + { | |
| 418 | + searchTraceIds: async () => ['t1'], | |
| 419 | + getTrace: async () => spans, | |
| 420 | + writeFile: (p, c) => {written[p] = c;}, | |
| 421 | + readFile: (p) => (p.endsWith(file) ? `${'\n'.repeat(2)} ${source}\n` : undefined), | |
| 422 | + log: (m) => logged.push(m), | |
| 423 | + readCallSites: () => [{ | |
| 424 | + traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff', | |
| 425 | + frames: [{file, line: 3, text: source}], | |
| 426 | + }], | |
| 427 | + }, | |
| 428 | + ); | |
| 429 | + const puml = written['/repo/petclinic-test/generated/add-visit.spec.ts.add-a-visit.genseq.puml']; | |
| 430 | + expect(puml).toContain(`[[src://petclinic-frontend/${file}:3{Click to open the line that sent ` | |
| 431 | + + 'this request: visit-add.component.ts:3} '); | |
| 432 | + expect(written['/repo/petclinic-test/test-results/trace-spans.json']).toContain('genseq.call-site'); | |
| 433 | + expect(logged.join('\n')).toContain('1/1 browser request(s) traced to their line'); | |
| 434 | +}); | |
| 435 | + | |
| 436 | +// The recorded headers reach the request's panel through the span cache, and the route the | |
| 437 | +// SERVER span matched names its controller under the words (Victor, 10 Nov 2026). | |
| 438 | +test('a request’s headers reach its panel, and its controller its call', async () => { | |
| 439 | + const written: Record<string, string> = {}; | |
| 440 | + const controllerFile = '/repo/petclinic-backend/src/main/java/VisitController.java'; | |
| 441 | + const controllerSource = [ | |
| 442 | + '@RestController', '@RequestMapping("/api")', 'public class VisitController {', | |
| 443 | + ' @PostMapping("/visits")', ' public Visit addVisit(Visit visit) {', ' }', '}', | |
| 444 | + ].join('\n'); | |
| 445 | + const spans = JSON.parse(JSON.stringify(fixture)); | |
| 446 | + const client = spans.batches[0].scopeSpans[0].spans[1]; | |
| 447 | + client.traceId = 'fE7B4usvB9KFqEyldpZ53Q=='; | |
| 448 | + client.spanId = 'lrlBhfFN7/8='; | |
| 449 | + spans.batches[1].scopeSpans[0].spans[0].parentSpanId = 'lrlBhfFN7/8='; | |
| 450 | + await generateFromWindows( | |
| 451 | + [{title: 'Add a visit', source: 'src/add-visit.spec.ts', startMs: 0, endMs: 10_000}], | |
| 452 | + '/repo/petclinic-test', | |
| 453 | + { | |
| 454 | + searchTraceIds: async () => ['t1'], | |
| 455 | + getTrace: async () => spans, | |
| 456 | + writeFile: (p, c) => {written[p] = c;}, | |
| 457 | + readFile: (p) => (p === controllerFile ? controllerSource : undefined), | |
| 458 | + walkFiles: (dir) => (controllerFile.startsWith(`${dir}/`) ? [controllerFile] : []), | |
| 459 | + log: () => undefined, | |
| 460 | + readCallSites: () => [{ | |
| 461 | + traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff', frames: [], | |
| 462 | + headers: {'Content-Type': 'application/json'}, | |
| 463 | + }], | |
| 464 | + }, | |
| 465 | + {}, {...DEFAULT_DIAGRAM_OPTIONS, handler: 'call'}, | |
| 466 | + ); | |
| 467 | + const puml = written['/repo/petclinic-test/generated/add-visit.spec.ts.add-a-visit.genseq.puml']; | |
| 468 | + expect(puml).toContain('Backend -> Backend: [[src://petclinic-backend/src/main/java/VisitController.java:5' | |
| 469 | + + '{Click to open VisitController.addVisit} VisitController.addVisit \u2197]]'); | |
| 470 | + const details = JSON.parse(written['/repo/petclinic-test/generated/add-visit.spec.ts.add-a-visit.genseq.json']); | |
| 471 | + const request = Object.values<any>(details.details).find((e) => e.request); | |
| 472 | + expect(request.request.headers).toEqual({'Content-Type': 'application/json'}); | |
| 473 | + expect(request.steps[0].text).toMatch(/^POST \/api\/visits\nContent-Type: application\/json\n\n\{/); | |
| 474 | + expect(written['/repo/petclinic-test/test-results/trace-spans.json']).toContain('genseq.request-headers'); | |
| 475 | +}); |
| 1 | 1 | import * as fs from 'fs'; |
| 2 | 2 | import * as path from 'path'; |
| 3 | 3 | import {readWindows} from '../support/trace-window-store'; |
| 4 | -import {parseTempoTrace, renderDiagram, DiagramScenario, MethodLinks, NormSpan, | |
| 5 | - PARTICIPANT_ATTRIBUTE, TEST_PARTICIPANT} from './trace-to-puml'; | |
| 4 | +import {readCallSites} from '../support/call-sites'; | |
| 5 | +import {parseTempoTrace, renderDiagram, ClientMethods, Controllers, DiagramScenario, MethodLinks, | |
| 6 | + NormSpan, PARTICIPANT_ATTRIBUTE, TEST_PARTICIPANT} from './trace-to-puml'; | |
| 6 | 7 | import {tempoConfigFromEnv, searchTraceIds, getTrace} from './tempo-client'; |
| 7 | 8 | import {DEFAULT_DIAGRAM_OPTIONS, DiagramOptions, describeOptions, optionsFromEnv} from './options'; |
| 8 | 9 | import {lineOfHandle, lineOfStep, lineOfTest, stepHandle, testHandle} from './test-location'; |
| 9 | -import {methodHandle} from './code-location'; | |
| 10 | +import {methodHandle, overloadOf} from './code-location'; | |
| 11 | +import {CALL_SITE_ATTRIBUTE, CallFrame, CallSite, callSiteHandle, callSiteIndex, withCallSites} | |
| 12 | + from './call-site'; | |
| 13 | +import {Handler, WalkFiles, handlerOf, moduleHandlers} from './controller-location'; | |
| 14 | +import {clientMethodOf} from './client-method'; | |
| 10 | 15 | import {defaultOperations} from './openapi-operations'; |
| 11 | 16 | |
| 12 | 17 | export interface TestWindow { |
| export interface RenderDeps { | ||
| 30 | 35 | /** Optional: what a directory holds, so a renamed or deleted scenario's diagram can be |
| 31 | 36 | * swept rather than left on disk describing a run that no longer happens. */ |
| 32 | 37 | listFiles?: (dir: string) => string[]; |
| 38 | + /** Optional: every file under a directory, so a request's route can be resolved against | |
| 39 | + * the controllers of the service it reached. Without it no request names its controller. */ | |
| 40 | + walkFiles?: WalkFiles; | |
| 33 | 41 | log: (msg: string) => void; |
| 34 | 42 | } |
| 35 | 43 | |
| export interface GenerateDeps extends RenderDeps { | ||
| 37 | 45 | searchTraceIds: (traceql: string, startMs: number, endMs: number) => Promise<string[]>; |
| 38 | 46 | getTrace: (traceId: string) => Promise<unknown>; |
| 39 | 47 | sleep?: (ms: number) => Promise<void>; |
| 48 | + /** Optional: which line sent each request. Without it the request arrows carry no link. */ | |
| 49 | + readCallSites?: () => CallSite[]; | |
| 40 | 50 | } |
| 41 | 51 | |
| 42 | 52 | /** One source file's scenarios, as fetched from Tempo — the input a re-render replays. */ |
| export async function generateFromWindows( | ||
| 215 | 225 | options: DiagramOptions = DEFAULT_DIAGRAM_OPTIONS, |
| 216 | 226 | ): Promise<string[]> { |
| 217 | 227 | const fetched: CachedSource[] = []; |
| 228 | + const callSites = callSiteIndex(deps.readCallSites?.() ?? []); | |
| 218 | 229 | // One diagram per source file, one section per scenario in it — so the picture |
| 219 | 230 | // is filed where its test is, and reads in the order the file does. |
| 220 | 231 | for (const [source, group] of groupBySource(windows)) { |
| export async function generateFromWindows( | ||
| 231 | 242 | } |
| 232 | 243 | const traces: NormSpan[][] = []; |
| 233 | 244 | for (const id of ids) { |
| 234 | - traces.push(parseTempoTrace(await deps.getTrace(id))); | |
| 245 | + traces.push(withCallSites(parseTempoTrace(await deps.getTrace(id)), callSites)); | |
| 235 | 246 | } |
| 236 | 247 | scenarios.push({title: w.title, traces: chronological(traces), |
| 237 | 248 | ...(w.selected ? {selected: true} : {})}); |
| 238 | - deps.log(`✅ "${w.title}": ${ids.length} trace(s)`); | |
| 249 | + deps.log(`✅ "${w.title}": ${ids.length} trace(s)${locatedRequests(traces)}`); | |
| 239 | 250 | } catch (err) { |
| 240 | 251 | // One scenario's Tempo error must not cost every other diagram — the runner |
| 241 | 252 | // deletes them all up front, so an abort here leaves fewer on disk than it found. |
| export async function generateFromWindows( | ||
| 257 | 268 | return renderScenarios(fetched, rootDir, deps, options); |
| 258 | 269 | } |
| 259 | 270 | |
| 271 | +/** Said out loud, because a request that lost its link does not show up any other way. */ | |
| 272 | +function locatedRequests(traces: NormSpan[][]): string { | |
| 273 | + const browser = traces.flat().filter( | |
| 274 | + (s) => s.kind === 'CLIENT' && s.serviceName === 'petclinic-frontend'); | |
| 275 | + if (browser.length === 0) return ''; | |
| 276 | + const located = browser.filter((s) => CALL_SITE_ATTRIBUTE in s.attributes); | |
| 277 | + return `, ${located.length}/${browser.length} browser request(s) traced to their line`; | |
| 278 | +} | |
| 279 | + | |
| 260 | 280 | /** |
| 261 | 281 | * Point every section header at the test that produced it. |
| 262 | 282 | * |
| function methodLinksFor(rootDir: string, deps: RenderDeps, source: string): Meth | ||
| 304 | 324 | return stepHandle(repoRelative(rootDir, source), |
| 305 | 325 | lineOfStep(text, span.name, lineOfHandle(scenario?.link))); |
| 306 | 326 | } |
| 327 | + // A browser request knows the line that sent it, in this run: no lookup, only a check | |
| 328 | + // that this checkout still has that line. | |
| 329 | + const callSite = span.attributes[CALL_SITE_ATTRIBUTE]; | |
| 330 | + if (callSite) return callSiteHandle(callSite, span.serviceName, repoRoot, readFile); | |
| 307 | 331 | // One trace draws the same repository method many times over — an N+1 is exactly what |
| 308 | 332 | // these pictures are for — and each hit would otherwise re-read and re-scan the file. |
| 333 | + // The overload is part of the key: `normalize(phone)` and `normalize(phone, country)` | |
| 334 | + // share a class and a name and live on different lines. | |
| 309 | 335 | const key = [span.serviceName, span.attributes['code.namespace'], |
| 310 | - span.attributes['code.function']].join('#'); | |
| 336 | + span.attributes['code.function'], JSON.stringify(overloadOf(span.attributes))].join('#'); | |
| 311 | 337 | if (!cache.has(key)) { |
| 312 | 338 | cache.set(key, methodHandle(span.attributes, span.serviceName, repoRoot, readFile)); |
| 313 | 339 | } |
| function methodLinksFor(rootDir: string, deps: RenderDeps, source: string): Meth | ||
| 315 | 341 | }; |
| 316 | 342 | } |
| 317 | 343 | |
| 344 | +/** | |
| 345 | + * Name the frontend method each browser request went through — see client-method.ts. | |
| 346 | + * Needs the working tree like the links above; without `readFile` the requests are drawn | |
| 347 | + * straight off the Browser lifeline, as they always were. | |
| 348 | + */ | |
| 349 | +function clientMethodsFor(rootDir: string, deps: RenderDeps): ClientMethods { | |
| 350 | + const readFile = deps.readFile; | |
| 351 | + if (!readFile) return () => undefined; | |
| 352 | + const repoRoot = path.dirname(rootDir); | |
| 353 | + const cache = new Map<string, string | undefined>(); | |
| 354 | + return (span) => { | |
| 355 | + const attribute = span.attributes[CALL_SITE_ATTRIBUTE]; | |
| 356 | + if (span.kind !== 'CLIENT' || !attribute) return undefined; | |
| 357 | + const key = `${span.spanId}#${attribute}`; | |
| 358 | + if (!cache.has(key)) { | |
| 359 | + let frames: CallFrame[] = []; | |
| 360 | + try { | |
| 361 | + frames = JSON.parse(attribute); | |
| 362 | + } catch { | |
| 363 | + // an unreadable attribute is a request with no known call site | |
| 364 | + } | |
| 365 | + const request = { | |
| 366 | + verb: span.attributes['http.method'] ?? span.attributes['http.request.method'], | |
| 367 | + url: span.attributes['http.url'] ?? span.attributes['url.full'], | |
| 368 | + }; | |
| 369 | + cache.set(key, clientMethodOf(frames, request, `${repoRoot}/${span.serviceName}`, readFile)); | |
| 370 | + } | |
| 371 | + return cache.get(key); | |
| 372 | + }; | |
| 373 | +} | |
| 374 | + | |
| 375 | +/** | |
| 376 | + * The controller method each request landed on — a method the trace never names, resolved by | |
| 377 | + * the SERVER span's route against that service's sources (controller-location.ts), scanned | |
| 378 | + * once per module. Needs the working tree and a way to walk it; without either, no request | |
| 379 | + * names its controller. | |
| 380 | + */ | |
| 381 | +function controllersFor(rootDir: string, deps: RenderDeps): Controllers { | |
| 382 | + const {readFile, walkFiles: walk} = deps; | |
| 383 | + if (!readFile || !walk) return () => undefined; | |
| 384 | + const repoRoot = path.dirname(rootDir); | |
| 385 | + const handlers = new Map<string, Map<string, Handler>>(); | |
| 386 | + return (span) => { | |
| 387 | + if (span.kind !== 'SERVER') return undefined; | |
| 388 | + let routes = handlers.get(span.serviceName); | |
| 389 | + if (!routes) { | |
| 390 | + routes = moduleHandlers(span.serviceName, repoRoot, readFile, walk); | |
| 391 | + handlers.set(span.serviceName, routes); | |
| 392 | + } | |
| 393 | + return handlerOf(span, routes); | |
| 394 | + }; | |
| 395 | +} | |
| 396 | + | |
| 318 | 397 | /** `source` resolved against the repo root — the path the review page can open. */ |
| 319 | 398 | function repoRelative(rootDir: string, source: string): string { |
| 320 | 399 | return path.normalize(path.join(path.basename(rootDir), source)); |
| function titleFor(rootDir: string, source: string): string { | ||
| 331 | 410 | return source.startsWith('..') ? repoRelative(rootDir, source) : source; |
| 332 | 411 | } |
| 333 | 412 | |
| 413 | +/** Every file under `dir`, absolute; nothing for a directory that is not there. */ | |
| 414 | +function walkFiles(dir: string): string[] { | |
| 415 | + if (!fs.existsSync(dir)) return []; | |
| 416 | + return fs.readdirSync(dir, {recursive: true, withFileTypes: true}) | |
| 417 | + .filter((entry) => entry.isFile()) | |
| 418 | + .map((entry) => path.join(entry.parentPath ?? (entry as any).path, entry.name)); | |
| 419 | +} | |
| 420 | + | |
| 334 | 421 | /** Draw the diagrams from spans already in hand — the offline half of the pipeline. */ |
| 335 | 422 | export function renderScenarios( |
| 336 | 423 | sources: CachedSource[], rootDir: string, deps: RenderDeps, |
| export function renderScenarios( | ||
| 338 | 425 | ): string[] { |
| 339 | 426 | const written: string[] = []; |
| 340 | 427 | warnOnCollisions(sources, deps); |
| 428 | + const controllers = controllersFor(rootDir, deps); | |
| 341 | 429 | for (const {source, scenarios} of sources) { |
| 342 | 430 | const linked = linkScenarios(scenarios, rootDir, source, deps); |
| 343 | 431 | const title = titleFor(rootDir, source); |
| 344 | 432 | const methodLinks = methodLinksFor(rootDir, deps, source); |
| 433 | + const clientMethods = clientMethodsFor(rootDir, deps); | |
| 345 | 434 | const slugs = uniqueSlugs(linked.map((s) => s.title)); |
| 346 | 435 | const mine = new Set<string>(); |
| 347 | 436 | linked.forEach((scenario, i) => { |
| export function renderScenarios( | ||
| 351 | 440 | // One scenario per call, so the divider `renderDiagram` draws is this diagram's |
| 352 | 441 | // own header and the picture under it is one test from end to end. |
| 353 | 442 | const {puml, details} = renderDiagram( |
| 354 | - title, [scenario], options, defaultOperations(), methodLinks); | |
| 443 | + title, [scenario], options, defaultOperations(), methodLinks, clientMethods, controllers); | |
| 355 | 444 | // A scenario whose traces draw nothing — a lone click, a run that recorded no |
| 356 | 445 | // server span — used to cost a header inside a shared file and nothing more. On its |
| 357 | 446 | // own it would be a file containing a title and no conversation, which the review |
| export async function runGenerate(owned?: RegExp): Promise<void> { | ||
| 490 | 579 | readFile: (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : undefined), |
| 491 | 580 | removeFile: (p) => fs.rmSync(p, {force: true}), |
| 492 | 581 | listFiles: (d) => (fs.existsSync(d) ? fs.readdirSync(d) : []), |
| 582 | + walkFiles, | |
| 493 | 583 | log: (m) => console.log(m), |
| 494 | 584 | }, options); |
| 495 | 585 | console.log(`📊 Re-rendered ${paths.length} diagram(s) from ${cacheFile} — Grafana not needed`); |
| export async function runGenerate(owned?: RegExp): Promise<void> { | ||
| 511 | 601 | return; |
| 512 | 602 | } |
| 513 | 603 | |
| 514 | - // GRAFANA_URL names whose Tempo this is: an isolated `start-docker.sh up --otel` | |
| 515 | - // instance prints its own under `ports`; unset, the dev stack's :3300. | |
| 604 | + // TEMPO_URL is a bare Tempo (../start-tempo.sh, no Docker); otherwise GRAFANA_URL names | |
| 605 | + // whose Tempo this is: an isolated `start-docker.sh up --otel` instance prints its own | |
| 606 | + // under `ports`; unset, the dev stack's :3300. | |
| 516 | 607 | const cfg = tempoConfigFromEnv(); |
| 517 | 608 | console.log(`🔭 Tempo behind ${cfg.baseUrl}`); |
| 518 | 609 | const deps: GenerateDeps = { |
| 519 | 610 | searchTraceIds: (q, s, e) => searchTraceIds(cfg, q, s, e), |
| 520 | 611 | getTrace: (id) => getTrace(cfg, id), |
| 612 | + readCallSites: () => readCallSites(path.join(root, 'test-results', 'call-sites')), | |
| 521 | 613 | writeFile: writeFileMakingDirs, |
| 522 | 614 | readFile: (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : undefined), |
| 523 | 615 | removeFile: (p) => fs.rmSync(p, {force: true}), |
| 524 | 616 | listFiles: (d) => (fs.existsSync(d) ? fs.readdirSync(d) : []), |
| 617 | + walkFiles, | |
| 525 | 618 | log: (m) => console.log(m), |
| 526 | 619 | }; |
| 527 | 620 |
| 1 | +import {expect, test} from '@playwright/test'; | |
| 2 | +import type {NormSpan} from './trace-to-puml'; | |
| 3 | +import {recordedRequest, requestMessage} from './http-request'; | |
| 4 | + | |
| 5 | +// The request an HTTP arrow's panel shows and the review page replays: the URL that was | |
| 6 | +// really sent, with its ids, never the route the server matched it to. | |
| 7 | + | |
| 8 | +const span = (kind: string, name: string, attributes: Record<string, string>): NormSpan => ({ | |
| 9 | + traceId: 't', spanId: kind, parentSpanId: '', name, kind, serviceName: 'petclinic-backend', | |
| 10 | + startNano: 0, attributes, | |
| 11 | +}); | |
| 12 | +const server = (attributes: Record<string, string> = {}) => | |
| 13 | + span('SERVER', 'GET /api/owners/{ownerId}', attributes); | |
| 14 | + | |
| 15 | +test('the URL is the one the client sent, with its ids', () => { | |
| 16 | + const browser = span('CLIENT', 'GET', {'http.method': 'GET', 'http.url': 'http://127.0.0.1:8080/api/owners/1'}); | |
| 17 | + expect(recordedRequest(server(), browser, undefined)) | |
| 18 | + .toEqual({method: 'GET', url: 'http://127.0.0.1:8080/api/owners/1'}); | |
| 19 | + const java = span('CLIENT', 'POST', {'http.request.method': 'POST', | |
| 20 | + 'url.full': 'http://notification:8090/api/notifications/visit-booked'}); | |
| 21 | + expect(recordedRequest(server(), java, undefined).url) | |
| 22 | + .toBe('http://notification:8090/api/notifications/visit-booked'); | |
| 23 | +}); | |
| 24 | + | |
| 25 | +test('with no client to ask, the URL is put back together from the server’s account', () => { | |
| 26 | + const received = server({'url.scheme': 'http', 'server.address': 'notification', 'server.port': '8090', | |
| 27 | + 'url.path': '/api/owners/1', 'url.query': 'x=1'}); | |
| 28 | + expect(recordedRequest(received, undefined, undefined).url).toBe('http://notification:8090/api/owners/1?x=1'); | |
| 29 | + // A parent that did not send it (a test's own span) is no client either. | |
| 30 | + expect(recordedRequest(received, span('INTERNAL', 'when', {'http.url': 'http://wrong'}), undefined).url) | |
| 31 | + .toBe('http://notification:8090/api/owners/1?x=1'); | |
| 32 | +}); | |
| 33 | + | |
| 34 | +// An address made up here would send a replay somewhere nobody listens. | |
| 35 | +test('a server that does not say where it was reached gives the path alone', () => { | |
| 36 | + expect(recordedRequest(server({'url.path': '/api/owners/1'}), undefined, undefined).url).toBe('/api/owners/1'); | |
| 37 | + expect(recordedRequest(server({'http.target': '/api/owners?lastName=P'}), undefined, undefined).url) | |
| 38 | + .toBe('/api/owners?lastName=P'); | |
| 39 | + expect(recordedRequest(server(), undefined, undefined).url).toBe('/api/owners/{ownerId}'); | |
| 40 | +}); | |
| 41 | + | |
| 42 | +test('the method comes from the attributes, else from a span name', () => { | |
| 43 | + expect(recordedRequest(server({'http.request.method': 'put'}), undefined, undefined).method).toBe('PUT'); | |
| 44 | + expect(recordedRequest(span('SERVER', 'DELETE /api/pets/{id}', {}), undefined, undefined).method) | |
| 45 | + .toBe('DELETE'); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +test('the headers come from the client span, and a broken record is no headers', () => { | |
| 49 | + const sent = (headers: string) => span('CLIENT', 'GET', {'genseq.request-headers': headers}); | |
| 50 | + expect(recordedRequest(server(), sent('{"Accept":"application/json"}'), undefined).headers) | |
| 51 | + .toEqual({Accept: 'application/json'}); | |
| 52 | + expect(recordedRequest(server(), sent('not json'), undefined)).not.toHaveProperty('headers'); | |
| 53 | + expect(recordedRequest(server(), sent('{}'), undefined)).not.toHaveProperty('headers'); | |
| 54 | + expect(recordedRequest(server(), sent('["a"]'), undefined)).not.toHaveProperty('headers'); | |
| 55 | +}); | |
| 56 | + | |
| 57 | +test('the body is kept raw, and an empty one is no body', () => { | |
| 58 | + expect(recordedRequest(server(), undefined, '{"a": 1}').body).toBe('{"a": 1}'); | |
| 59 | + expect(recordedRequest(server(), undefined, ' ')).not.toHaveProperty('body'); | |
| 60 | +}); | |
| 61 | + | |
| 62 | +test('the message is a request line, a line per header, a blank line and the body', () => { | |
| 63 | + expect(requestMessage({method: 'POST', url: 'http://h/api/visits', | |
| 64 | + headers: {'Content-Type': 'application/json', Accept: '*/*'}, body: '{"a":1}'})) | |
| 65 | + .toBe('POST http://h/api/visits\nContent-Type: application/json\nAccept: */*\n\n{\n "a": 1\n}'); | |
| 66 | + expect(requestMessage({method: 'GET', url: 'http://h/api/owners/1'})).toBe('GET http://h/api/owners/1'); | |
| 67 | +}); |
| 1 | +import type {NormSpan} from './trace-to-puml'; | |
| 2 | +import type {HttpRequest} from './detail-index'; | |
| 3 | +import {REQUEST_HEADERS_ATTRIBUTE} from './call-site'; | |
| 4 | +import {formatJsonDetail} from './json-label'; | |
| 5 | + | |
| 6 | +/** | |
| 7 | + * An HTTP arrow's request, as it went over the wire: what its detail panel shows, and what | |
| 8 | + * the review page's "Open in Bruno" sends again (Victor, 10 Nov 2026). | |
| 9 | + * | |
| 10 | + * The arrow is drawn from the SERVER span, which knows the route the request matched | |
| 11 | + * (`/api/owners/{ownerId}`) — a template, which no client can send. The span that *sent* it | |
| 12 | + * knows the URL with the real ids in it, so that comes first: the browser's CLIENT span | |
| 13 | + * records `http.url`, a Java client's `url.full`. Without a CLIENT parent (a service a test | |
| 14 | + * called straight), the URL is put back together from what the server says it received. | |
| 15 | + */ | |
| 16 | + | |
| 17 | +const VERB = /^([A-Z]+)\b/; | |
| 18 | + | |
| 19 | +function clientOf(parent: NormSpan | undefined): NormSpan | undefined { | |
| 20 | + return parent?.kind === 'CLIENT' ? parent : undefined; | |
| 21 | +} | |
| 22 | + | |
| 23 | +function methodOf(server: NormSpan, client: NormSpan | undefined): string { | |
| 24 | + for (const span of [client, server]) { | |
| 25 | + const method = span?.attributes['http.method'] ?? span?.attributes['http.request.method']; | |
| 26 | + if (method) return method.toUpperCase(); | |
| 27 | + } | |
| 28 | + return VERB.exec(server.name.trim())?.[1] ?? VERB.exec(client?.name.trim() ?? '')?.[1] ?? 'GET'; | |
| 29 | +} | |
| 30 | + | |
| 31 | +/** | |
| 32 | + * `http://127.0.0.1:8080/api/owners/1?x=y`, from the server's own account of the request. | |
| 33 | + * A server that does not say which host it was reached at (a test's in-process call) gives | |
| 34 | + * the path alone: an address made up here would send a replay somewhere nobody listens. | |
| 35 | + */ | |
| 36 | +function urlFromServer(server: NormSpan): string { | |
| 37 | + const a = server.attributes; | |
| 38 | + const target = a['http.target']; | |
| 39 | + const path = a['url.path'] ?? target?.split('?')[0] ?? server.name.trim().split(/\s+/)[1] ?? '/'; | |
| 40 | + const query = a['url.query'] ?? (target?.includes('?') ? target.slice(target.indexOf('?') + 1) : undefined); | |
| 41 | + const local = `${path}${query ? `?${query}` : ''}`; | |
| 42 | + const host = a['server.address'] ?? a['net.host.name']; | |
| 43 | + if (!host) return local; | |
| 44 | + const port = a['server.port'] ?? a['net.host.port']; | |
| 45 | + return `${a['url.scheme'] ?? a['http.scheme'] ?? 'http'}://${host}${port ? `:${port}` : ''}${local}`; | |
| 46 | +} | |
| 47 | + | |
| 48 | +function headersOf(span: NormSpan | undefined): Record<string, string> | undefined { | |
| 49 | + const raw = span?.attributes[REQUEST_HEADERS_ATTRIBUTE]; | |
| 50 | + if (!raw) return undefined; | |
| 51 | + try { | |
| 52 | + const parsed = JSON.parse(raw); | |
| 53 | + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; | |
| 54 | + const entries = Object.entries(parsed).filter(([, v]) => typeof v === 'string') as [string, string][]; | |
| 55 | + return entries.length > 0 ? Object.fromEntries(entries) : undefined; | |
| 56 | + } catch { | |
| 57 | + return undefined; | |
| 58 | + } | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** | |
| 62 | + * The request a SERVER span received, with `parent` the span that sent it. `body` is the raw | |
| 63 | + * payload, or undefined to leave it out — the diagram's options decide whether it is carried. | |
| 64 | + * A span cache recorded before the headers were captured simply has none to give. | |
| 65 | + */ | |
| 66 | +export function recordedRequest( | |
| 67 | + server: NormSpan, parent: NormSpan | undefined, body: string | undefined, | |
| 68 | +): HttpRequest { | |
| 69 | + const client = clientOf(parent); | |
| 70 | + const url = client?.attributes['http.url'] ?? client?.attributes['url.full'] ?? urlFromServer(server); | |
| 71 | + const headers = headersOf(client) ?? headersOf(server); | |
| 72 | + return { | |
| 73 | + method: methodOf(server, client), | |
| 74 | + url, | |
| 75 | + ...(headers ? {headers} : {}), | |
| 76 | + ...(body?.trim() ? {body} : {}), | |
| 77 | + }; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * The request as an HTTP message: the request line, a `Name: value` line per header, then a | |
| 82 | + * blank line and the body. The shape every HTTP client and RFC prints, so a reader takes it in | |
| 83 | + * at a glance — and the body pretty-printed, which is the one liberty taken with the wire. | |
| 84 | + */ | |
| 85 | +export function requestMessage(request: HttpRequest): string { | |
| 86 | + const head = [`${request.method} ${request.url}`, | |
| 87 | + ...Object.entries(request.headers ?? {}).map(([name, value]) => `${name}: ${value}`)]; | |
| 88 | + const body = formatJsonDetail(request.body); | |
| 89 | + return body ? `${head.join('\n')}\n\n${body}` : head.join('\n'); | |
| 90 | +} |
| import {test, expect} from '@playwright/test'; | ||
| 2 | 2 | import {DEFAULT_DIAGRAM_OPTIONS, describeOptions, optionsFromEnv} from './options'; |
| 3 | 3 | |
| 4 | 4 | test('an empty environment reveals everything the traces carry, a click at a time', () => { |
| 5 | - expect(optionsFromEnv({})).toEqual({sql: 'statement', httpBodies: true, interactive: true}); | |
| 5 | + expect(optionsFromEnv({})).toEqual({sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'}); | |
| 6 | 6 | expect(optionsFromEnv({})).toEqual(DEFAULT_DIAGRAM_OPTIONS); |
| 7 | 7 | }); |
| 8 | 8 | |
| test('SEQ_INTERACTIVE=0 goes back to baking the detail into the picture', () => | ||
| 34 | 34 | }); |
| 35 | 35 | |
| 36 | 36 | test('describeOptions states the level a generated file was rendered at', () => { |
| 37 | - expect(describeOptions({sql: 'values', httpBodies: true, interactive: false})) | |
| 38 | - .toBe('SQL shown, with values · HTTP bodies shown'); | |
| 39 | - expect(describeOptions({sql: 'off', httpBodies: false, interactive: false})) | |
| 40 | - .toBe('SQL not shown · HTTP bodies not shown'); | |
| 37 | + expect(describeOptions({sql: 'values', httpBodies: true, interactive: false, handler: 'panel'})) | |
| 38 | + .toBe('SQL shown, with values · HTTP bodies shown · controllers in the request panel'); | |
| 39 | + expect(describeOptions({sql: 'off', httpBodies: false, interactive: false, handler: 'call'})) | |
| 40 | + .toBe('SQL not shown · HTTP bodies not shown · controllers as calls'); | |
| 41 | 41 | }); |
| 42 | 42 | |
| 43 | 43 | // An interactive diagram shows none of it up front, so naming a level would lie — |
| 44 | -// what the header owes the reader is what a click will bring up. | |
| 44 | +// what the header owes the reader is what a click will bring up. An HTTP request always | |
| 45 | +// opens, its URL and headers there to show with every payload switched off. | |
| 45 | 46 | test('describeOptions names what is revealable rather than what is drawn', () => { |
| 46 | - expect(describeOptions({sql: 'statement', httpBodies: true, interactive: true})) | |
| 47 | - .toBe('simplified · click an arrow to reveal its SQL / JSON payloads'); | |
| 48 | - expect(describeOptions({sql: 'statement', httpBodies: false, interactive: true})) | |
| 49 | - .toBe('simplified · click an arrow to reveal its SQL'); | |
| 50 | - expect(describeOptions({sql: 'off', httpBodies: false, interactive: true})) | |
| 51 | - .toBe('simplified · nothing recorded to reveal'); | |
| 47 | + expect(describeOptions({sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'})) | |
| 48 | + .toBe('simplified · click an arrow to reveal its SQL / HTTP requests / JSON payloads' | |
| 49 | + + ' · controllers in the request panel'); | |
| 50 | + expect(describeOptions({sql: 'statement', httpBodies: false, interactive: true, handler: 'panel'})) | |
| 51 | + .toBe('simplified · click an arrow to reveal its SQL / HTTP requests · controllers in the request panel'); | |
| 52 | + expect(describeOptions({sql: 'off', httpBodies: false, interactive: true, handler: 'call'})) | |
| 53 | + .toBe('simplified · click an arrow to reveal its HTTP requests · controllers as calls'); | |
| 54 | +}); | |
| 55 | + | |
| 56 | +// The request's words open the request now, so the controller needs a place of its own; both | |
| 57 | +// shapes are kept to compare on a real diagram (Victor, 10 Nov 2026). | |
| 58 | +test('SEQ_HANDLER leaves the controller to the panel, or draws it as a call', () => { | |
| 59 | + expect(optionsFromEnv({}).handler).toBe('panel'); | |
| 60 | + expect(optionsFromEnv({SEQ_HANDLER: 'call'}).handler).toBe('call'); | |
| 61 | + expect(optionsFromEnv({SEQ_HANDLER: ' CALL '}).handler).toBe('call'); | |
| 62 | + expect(optionsFromEnv({SEQ_HANDLER: 'line'}).handler).toBe('panel'); | |
| 63 | + expect(optionsFromEnv({SEQ_HANDLER: 'constructor'}).handler).toBe('panel'); | |
| 64 | + expect(optionsFromEnv({SEQ_HANDLER: 'yolo'}).handler).toBe(DEFAULT_DIAGRAM_OPTIONS.handler); | |
| 52 | 65 | }); |
| 53 | 66 | |
| 54 | 67 | // Payloads are free behind a click and expensive baked in, so the default follows the |
| 6 | 6 | /** `off` — no SQL; `statement` — the SQL as sent, `?` for each bound value; `values` — `?` filled in. */ |
| 7 | 7 | export type SqlDetail = 'off' | 'statement' | 'values'; |
| 8 | 8 | |
| 9 | +/** | |
| 10 | + * Where an HTTP arrow names the controller method its request landed on: `panel` — only in the | |
| 11 | + * head of the panel the arrow's words open, the review page resolving it from the route; `call` | |
| 12 | + * — a self-call on the receiving lifeline, everything the request did nested inside it. | |
| 13 | + * | |
| 14 | + * There used to be a `line`: a third line under the arrow's words, a link of its own. Two | |
| 15 | + * links on one label, each with its own hover and click, for a name the panel's head already | |
| 16 | + * gives — it read as clutter (Victor, 10 Nov 2026). `line` is still accepted, as `panel`. | |
| 17 | + */ | |
| 18 | +export type HandlerPlacement = 'panel' | 'call'; | |
| 19 | + | |
| 9 | 20 | export interface DiagramOptions { |
| 10 | 21 | sql: SqlDetail; |
| 11 | 22 | /** Draw the JSON request/response payloads of each REST round-trip as notes. */ |
| export interface DiagramOptions { | ||
| 16 | 27 | * say *what* the diagram carries; this says whether it is baked in or clicked open. |
| 17 | 28 | */ |
| 18 | 29 | interactive: boolean; |
| 30 | + /** | |
| 31 | + * The words of a request arrow open the request itself now, so the controller needs a | |
| 32 | + * place of its own. Both shapes are kept so they can be compared on a real diagram before | |
| 33 | + * one of them wins (Victor, 10 Nov 2026). | |
| 34 | + */ | |
| 35 | + handler: HandlerPlacement; | |
| 19 | 36 | } |
| 20 | 37 | |
| 21 | 38 | /** The default diagram: simplified, with everything the traces carry a click away. */ |
| export const DEFAULT_DIAGRAM_OPTIONS: DiagramOptions = { | ||
| 23 | 40 | sql: 'statement', |
| 24 | 41 | httpBodies: true, |
| 25 | 42 | interactive: true, |
| 43 | + handler: 'panel', | |
| 26 | 44 | }; |
| 27 | 45 | |
| 28 | 46 | // Object.create(null): a plain literal answers to `constructor` and `__proto__`, |
| const SQL_ALIASES: Record<string, SqlDetail> = Object.assign(Object.create(null) | ||
| 33 | 51 | values: 'values', params: 'values', |
| 34 | 52 | }); |
| 35 | 53 | |
| 54 | +// Object.create(null) for the reason SQL_ALIASES gives. | |
| 55 | +const HANDLER_ALIASES: Record<string, HandlerPlacement> = Object.assign(Object.create(null), { | |
| 56 | + panel: 'panel', line: 'panel', call: 'call', | |
| 57 | +}); | |
| 58 | + | |
| 36 | 59 | const TRUTHY = /^(1|true|yes|on)$/i; |
| 37 | 60 | const FALSY = /^(0|false|no|off)$/i; |
| 38 | 61 | |
| export function optionsFromEnv( | ||
| 55 | 78 | // a request arrow and found it had nothing to say. |
| 56 | 79 | httpBodies: boolFrom(env.SEQ_HTTP_BODIES, interactive), |
| 57 | 80 | interactive, |
| 81 | + handler: HANDLER_ALIASES[env.SEQ_HANDLER?.trim().toLowerCase() ?? ''] | |
| 82 | + ?? DEFAULT_DIAGRAM_OPTIONS.handler, | |
| 58 | 83 | }; |
| 59 | 84 | } |
| 60 | 85 | |
| 61 | -/** What an interactive diagram lets the reader click open, in the order it appears. */ | |
| 86 | +/** | |
| 87 | + * What an interactive diagram lets the reader click open, in the order it appears. An HTTP | |
| 88 | + * request always opens — its URL and headers are there to show with no payload switched on. | |
| 89 | + */ | |
| 62 | 90 | export function revealable(o: DiagramOptions): string[] { |
| 63 | 91 | const kinds: string[] = []; |
| 64 | 92 | if (o.sql !== 'off') kinds.push('SQL'); |
| 93 | + kinds.push('HTTP requests'); | |
| 65 | 94 | if (o.httpBodies) kinds.push('JSON payloads'); |
| 66 | 95 | return kinds; |
| 67 | 96 | } |
| 68 | 97 | |
| 98 | +const HANDLER_PLACES: Record<HandlerPlacement, string> = { | |
| 99 | + panel: 'controllers in the request panel', | |
| 100 | + call: 'controllers as calls', | |
| 101 | +}; | |
| 102 | + | |
| 69 | 103 | /** The line a generated file carries so its own detail level is never a guess. */ |
| 70 | 104 | export function describeOptions(o: DiagramOptions): string { |
| 105 | + const handler = HANDLER_PLACES[o.handler]; | |
| 71 | 106 | if (o.interactive) { |
| 72 | - const kinds = revealable(o); | |
| 73 | - if (kinds.length === 0) return 'simplified · nothing recorded to reveal'; | |
| 74 | - return `simplified · click an arrow to reveal its ${kinds.join(' / ')}`; | |
| 107 | + return `simplified · click an arrow to reveal its ${revealable(o).join(' / ')} · ${handler}`; | |
| 75 | 108 | } |
| 76 | 109 | const sql = {off: 'not shown', statement: 'shown, values as ?', values: 'shown, with values'}[o.sql]; |
| 77 | - return `SQL ${sql} · HTTP bodies ${o.httpBodies ? 'shown' : 'not shown'}`; | |
| 110 | + return `SQL ${sql} · HTTP bodies ${o.httpBodies ? 'shown' : 'not shown'} · ${handler}`; | |
| 78 | 111 | } |
| test('tempoConfigFromEnv falls back to localhost grafana admin', () => { | ||
| 10 | 10 | expect(cfg.password).toBe('admin'); |
| 11 | 11 | }); |
| 12 | 12 | |
| 13 | +test('TEMPO_URL talks to Tempo itself: no Grafana proxy, no login', async () => { | |
| 14 | + const cfg = tempoConfigFromEnv({TEMPO_URL: 'http://localhost:3200', GRAFANA_URL: 'http://localhost:3300'}); | |
| 15 | + expect(buildTraceUrl(cfg, 'abc123')).toBe('http://localhost:3200/api/traces/abc123'); | |
| 16 | + expect(buildSearchUrl(cfg, '{}', 1, 2, 3)).toMatch(/^http:\/\/localhost:3200\/api\/search\?/); | |
| 17 | + const seen: any[] = []; | |
| 18 | + const fakeFetch = async (_url: string, init: any) => { | |
| 19 | + seen.push(init.headers); | |
| 20 | + return {ok: true, json: async () => ({traces: []})}; | |
| 21 | + }; | |
| 22 | + await searchTraceIds(cfg, '{}', 0, 1, fakeFetch as any); | |
| 23 | + expect(seen[0].Authorization).toBeUndefined(); | |
| 24 | +}); | |
| 25 | + | |
| 13 | 26 | test('authHeader builds Basic base64', () => { |
| 14 | 27 | const h = authHeader({ baseUrl: 'x', user: 'admin', password: 'admin' }); |
| 15 | 28 | expect(h).toBe('Basic ' + Buffer.from('admin:admin').toString('base64')); |
| export interface TempoConfig { | ||
| 2 | 2 | baseUrl: string; |
| 3 | 3 | user: string; |
| 4 | 4 | password: string; |
| 5 | + /** baseUrl is Tempo itself (../start-tempo.sh, no Grafana): no datasource proxy, no login. */ | |
| 6 | + direct?: boolean; | |
| 5 | 7 | } |
| 6 | 8 | |
| 7 | 9 | type FetchFn = (url: string, init?: any) => Promise<{ ok: boolean; status?: number; json: () => Promise<any> }>; |
| type FetchFn = (url: string, init?: any) => Promise<{ ok: boolean; status?: numb | ||
| 9 | 11 | const TEMPO_DS = 'tempo'; |
| 10 | 12 | |
| 11 | 13 | export function tempoConfigFromEnv(env: NodeJS.ProcessEnv = process.env): TempoConfig { |
| 14 | + if (env.TEMPO_URL) { | |
| 15 | + return {baseUrl: env.TEMPO_URL, user: '', password: '', direct: true}; | |
| 16 | + } | |
| 12 | 17 | return { |
| 13 | 18 | baseUrl: env.GRAFANA_URL ?? 'http://localhost:3300', |
| 14 | 19 | user: env.GRAFANA_USER ?? 'admin', |
| export function authHeader(cfg: TempoConfig): string { | ||
| 21 | 26 | } |
| 22 | 27 | |
| 23 | 28 | function proxyBase(cfg: TempoConfig): string { |
| 24 | - return `${cfg.baseUrl}/api/datasources/proxy/uid/${TEMPO_DS}`; | |
| 29 | + return cfg.direct ? cfg.baseUrl : `${cfg.baseUrl}/api/datasources/proxy/uid/${TEMPO_DS}`; | |
| 30 | +} | |
| 31 | + | |
| 32 | +function authHeaders(cfg: TempoConfig): Record<string, string> { | |
| 33 | + return cfg.direct ? {} : {Authorization: authHeader(cfg)}; | |
| 25 | 34 | } |
| 26 | 35 | |
| 27 | 36 | export function buildSearchUrl( |
| export async function searchTraceIds( | ||
| 45 | 54 | fetchFn: FetchFn = fetch as any, |
| 46 | 55 | ): Promise<string[]> { |
| 47 | 56 | const url = buildSearchUrl(cfg, traceql, Math.floor(startMs / 1000), Math.ceil(endMs / 1000), 50); |
| 48 | - const res = await fetchFn(url, { headers: { Authorization: authHeader(cfg) } }); | |
| 57 | + const res = await fetchFn(url, { headers: authHeaders(cfg) }); | |
| 49 | 58 | if (!res.ok) { |
| 50 | 59 | throw new Error(`Tempo search failed: HTTP ${res.status}`); |
| 51 | 60 | } |
| export async function getTrace( | ||
| 58 | 67 | ): Promise<unknown> { |
| 59 | 68 | const url = buildTraceUrl(cfg, traceId); |
| 60 | 69 | const res = await fetchFn(url, { |
| 61 | - headers: { Authorization: authHeader(cfg), Accept: 'application/json' }, | |
| 70 | + headers: { ...authHeaders(cfg), Accept: 'application/json' }, | |
| 62 | 71 | }); |
| 63 | 72 | if (!res.ok) { |
| 64 | 73 | throw new Error(`Tempo getTrace failed: HTTP ${res.status}`); |
| test('the header wraps the title in the link, and is the bare title without one' | ||
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | 56 | // A @SpringBootTest's section is titled with the sentence JUnit shows for the method |
| 57 | -// (@DisplayNameGeneration(PrettyTestNames)), which is never written in the .java file — | |
| 57 | +// (PrettyTestNames, the backend's default generator), which is never written in the .java file — | |
| 58 | 58 | // the method is `adds_a_visit_to_an_existing_pet`. Matching the title as a literal |
| 59 | 59 | // substring therefore finds nothing, and the diagram loses the line it links to. |
| 60 | 60 | const JAVA = [ |
| const JAVA_METHOD = /\bvoid\s+([A-Za-z_$][\w$]*)\s*\(/; | ||
| 27 | 27 | * `sourceName` is the file the text came from. In a .java file the method declaration is |
| 28 | 28 | * looked for **first**, because there the title is never written out anyway: a |
| 29 | 29 | * @SpringBootTest's section is titled with the sentence JUnit displays for the method |
| 30 | - * (@DisplayNameGeneration(PrettyTestNames)), and the method is | |
| 30 | + * (PrettyTestNames, the backend's default generator), and the method is | |
| 31 | 31 | * `adds_a_visit_to_an_existing_pet`. Rather than reimplement the generator, the method |
| 32 | 32 | * name goes through the same camelCase/snake_case-to-words reading the DSL narration |
| 33 | 33 | * uses; the two agree because they are the same transformation. |
| import {test, expect} from '@playwright/test'; | ||
| 2 | 2 | import * as fs from 'fs'; |
| 3 | 3 | import * as path from 'path'; |
| 4 | 4 | import {NormSpan, parseTempoTrace, renderDiagram, renderPuml, spansToPuml} from './trace-to-puml'; |
| 5 | -import {DiagramOptions} from './options'; | |
| 5 | +import {DEFAULT_DIAGRAM_OPTIONS, DiagramOptions} from './options'; | |
| 6 | 6 | |
| 7 | 7 | // The diagrams are interactive by default now, so every assertion about a *baked in* |
| 8 | 8 | // label has to say so — which is also the regression test that the static path, the |
| 9 | 9 | // one CI and the committed snapshots depend on, still renders exactly what it did. |
| 10 | -const STATIC: DiagramOptions = {sql: 'statement', httpBodies: false, interactive: false}; | |
| 10 | +const STATIC: DiagramOptions = {sql: 'statement', httpBodies: false, interactive: false, handler: 'panel'}; | |
| 11 | 11 | |
| 12 | 12 | const fixture = JSON.parse( |
| 13 | 13 | fs.readFileSync(path.join(__dirname, '__fixtures__', 'add-visit-trace.json'), 'utf-8'), |
| test('spansToPuml footers the diagram with its provenance', () => { | ||
| 81 | 81 | // a PlantUML footer renders at the very bottom — provenance, not diagram content |
| 82 | 82 | expect(puml).toContain('footer @generate_sequence'); |
| 83 | 83 | // the title stays a clean single line, no subtitle, no note, no caption |
| 84 | - expect(puml).toContain('\ntitle add a visit\n'); | |
| 84 | + expect(puml).toContain('\ntitle Test: add a visit\n'); | |
| 85 | 85 | expect(puml).not.toContain('note across'); |
| 86 | 86 | expect(puml).not.toContain('caption'); |
| 87 | 87 | }); |
| test('renderPuml drops traces that would draw nothing', () => { | ||
| 202 | 202 | |
| 203 | 203 | // Two scenarios in, one drawn — so what comes out is a solo diagram: the survivor |
| 204 | 204 | // names the picture, and there is no chapter left to divide it from. |
| 205 | - expect(puml).toContain('title Add a visit'); | |
| 205 | + expect(puml).toContain('title Test: Add a visit'); | |
| 206 | 206 | expect(puml).not.toContain('Clicked around'); |
| 207 | 207 | expect(puml).not.toContain('== '); |
| 208 | 208 | expect(puml).toContain(ADD_VISIT); |
| test('a Java scenario is headed by its class, a Playwright one by itself', () => | ||
| 228 | 228 | const java = renderPuml( |
| 229 | 229 | 'petclinic-backend/src/test/java/victor/training/petclinic/rest/AddVisitApiTest.java', |
| 230 | 230 | [{title: 'remembers the vet who attended it', traces: [parseTempoTrace(fixture)]}]); |
| 231 | - expect(java).toContain('title AddVisitApiTest: remembers the vet who attended it'); | |
| 231 | + expect(java).toContain('title Test: AddVisitApiTest: remembers the vet who attended it'); | |
| 232 | 232 | |
| 233 | 233 | const spec = renderPuml('add-visit.spec.ts', |
| 234 | 234 | [{title: 'Add a visit', traces: [parseTempoTrace(fixture)]}]); |
| 235 | - expect(spec).toContain('title Add a visit'); | |
| 235 | + expect(spec).toContain('title Test: Add a visit'); | |
| 236 | 236 | |
| 237 | 237 | // Two chapters in one Java file are two qualified headings, for the same reason. |
| 238 | 238 | const two = renderPuml( |
| const MARKER = /\[\[genseq:\/\/([a-z0-9-]+)\{[^}]*\} [^\]]*\]\]/; | ||
| 253 | 253 | |
| 254 | 254 | interface Step {label: string; text: string; alternate?: {label: string; text: string}} |
| 255 | 255 | |
| 256 | -const detailOn = (line: string, details: Record<string, {title: string; steps: Step[]}>) => | |
| 256 | +interface Entry {title: string; steps: Step[]; request?: {url: string}} | |
| 257 | + | |
| 258 | +const detailOn = (line: string, details: Record<string, Entry>) => | |
| 257 | 259 | details[MARKER.exec(line)![1]]; |
| 258 | 260 | |
| 259 | 261 | const lineWith = (puml: string, needle: string) => |
| test('a statement with no bound parameters offers only the statement', () => { | ||
| 302 | 304 | test('the payloads become markers on the request and the response, not notes', () => { |
| 303 | 305 | const {puml, details} = renderDiagram('add-visit.spec.ts', |
| 304 | 306 | [{title: 'Add a visit', traces: [parseTempoTrace(fixture)]}], |
| 305 | - {sql: 'statement', httpBodies: true, interactive: true}); | |
| 307 | + {sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'}); | |
| 306 | 308 | |
| 307 | 309 | expect(puml).not.toContain('note over'); |
| 308 | 310 | |
| 311 | + // The request as an HTTP message: its request line, then — after a blank line — the body. | |
| 309 | 312 | const request = detailOn(lineWith(puml, 'Browser -> Backend:'), details.details); |
| 310 | - expect(request.steps[0].label).toBe('request body'); | |
| 313 | + expect(request.steps[0].label).toBe('request'); | |
| 314 | + expect(request.steps[0].text).toMatch(/^POST \/api\/visits\n\n\{\n/); | |
| 311 | 315 | expect(request.steps[0].text).toContain('"description": "annual checkup"'); |
| 312 | 316 | |
| 313 | 317 | const response = detailOn(lineWith(puml, 'Backend --> Browser:'), details.details); |
| test('a body recorded on a SERVER span reveals on its arrow, not on the calls it | ||
| 333 | 337 | span('r4', 'r3', 'send-sms', 'INTERNAL', 'mailer', {'genseq.participant': 'SMS gateway'}), |
| 334 | 338 | ]; |
| 335 | 339 | const {puml, details} = renderDiagram('add-visit.spec.ts', [{title: 'x', traces: [spans]}], |
| 336 | - {sql: 'statement', httpBodies: true, interactive: true}); | |
| 340 | + {sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'}); | |
| 337 | 341 | |
| 338 | 342 | const request = detailOn(lineWith(puml, 'Backend -> mailer:'), details.details); |
| 339 | 343 | expect(request.steps[0].text).toContain('"petName": "Leo"'); |
| test('a body recorded on a SERVER span reveals on its arrow, not on the calls it | ||
| 343 | 347 | // Behind a click a payload costs the picture nothing, so an interactive diagram carries |
| 344 | 348 | // it by default; baked in it is a wall of JSON, so a static one still has to ask. |
| 345 | 349 | // Withholding it by default only meant a reviewer clicked a request arrow and found |
| 346 | -// that it had nothing to say. | |
| 347 | -test('payloads are revealable by default, and nothing is when there is nothing to reveal', () => { | |
| 350 | +// that it had nothing to say. The request itself opens either way: its URL and headers | |
| 351 | +// are no payload (Victor, 10 Nov 2026). | |
| 352 | +test('payloads are revealable by default, and a request opens without them', () => { | |
| 348 | 353 | const plain = renderDiagram('x', [{title: 'x', traces: [parseTempoTrace(fixture)]}]); |
| 349 | 354 | expect(lineWith(plain.puml, 'Browser -> Backend:')).toMatch(MARKER); |
| 355 | + expect(lineWith(plain.puml, 'Backend --> Browser:')).toMatch(MARKER); | |
| 350 | 356 | |
| 351 | 357 | const withheld = renderDiagram('x', [{title: 'x', traces: [parseTempoTrace(fixture)]}], |
| 352 | - {sql: 'statement', httpBodies: false, interactive: true}); | |
| 353 | - expect(lineWith(withheld.puml, 'Browser -> Backend:')).not.toMatch(MARKER); | |
| 358 | + {sql: 'statement', httpBodies: false, interactive: true, handler: 'panel'}); | |
| 359 | + const request = detailOn(lineWith(withheld.puml, 'Browser -> Backend:'), withheld.details.details); | |
| 360 | + expect(request.steps).toEqual([{label: 'request', text: 'POST /api/visits'}]); | |
| 361 | + expect(lineWith(withheld.puml, 'Backend --> Browser:')).not.toMatch(MARKER); | |
| 354 | 362 | |
| 355 | 363 | const none = renderDiagram('x', [{title: 'x', traces: [parseTempoTrace(fixture)]}], |
| 356 | - {sql: 'off', httpBodies: false, interactive: true}); | |
| 357 | - expect(none.puml).not.toMatch(MARKER); | |
| 358 | - expect(none.details.details).toEqual({}); | |
| 364 | + {sql: 'off', httpBodies: false, interactive: true, handler: 'panel'}); | |
| 365 | + expect(Object.values(none.details.details).map((e) => e.title)) | |
| 366 | + .toEqual(['Browser → Backend: POST /api/visits']); | |
| 359 | 367 | }); |
| 360 | 368 | |
| 361 | 369 | // The review page renders a *textual* diff of the .puml, so an id that moved with |
| test('a marker id follows the arrow content, not its position in the file', () = | ||
| 372 | 380 | |
| 373 | 381 | test('an interactive diagram tones its link markup down', () => { |
| 374 | 382 | const {puml} = renderDiagram('x', [{title: 'x', traces: [parseTempoTrace(fixture)]}], |
| 375 | - {sql: 'values', httpBodies: true, interactive: true}); | |
| 383 | + {sql: 'values', httpBodies: true, interactive: true, handler: 'panel'}); | |
| 376 | 384 | expect(puml).toContain('skinparam hyperlinkUnderline false'); |
| 377 | 385 | expect(puml).toContain('skinparam hyperlinkColor'); |
| 378 | 386 | // The ⊕ on each revealable arrow is the affordance; a paragraph explaining it is not. |
| const selectOwners = { | ||
| 446 | 454 | attributes: {'db.system': 'postgresql', 'db.statement': 'select o1_0.id from owners o1_0'}, |
| 447 | 455 | }; |
| 448 | 456 | |
| 457 | +// Every frame says how its transaction ended, beside the tab — PlantUML's `[…]` tail. | |
| 458 | +const COMMITTED_TX = 'group tx [committed]'; | |
| 459 | + | |
| 449 | 460 | const inTransaction: NormSpan[] = [ |
| 450 | 461 | backendSpan('t1', '', 'GET /api/owners', 'SERVER'), |
| 451 | 462 | backendSpan('t2', 't1', 'OwnerRepository.findById', 'INTERNAL'), |
| const inTransaction: NormSpan[] = [ | ||
| 456 | 467 | |
| 457 | 468 | test('a transaction is drawn as a frame around what ran inside it', () => { |
| 458 | 469 | const body = spansToPuml(inTransaction, 'tx', STATIC).split('\n').map((l) => l.trim()); |
| 459 | - const open = body.indexOf('group tx'); | |
| 470 | + const open = body.indexOf(COMMITTED_TX); | |
| 460 | 471 | const close = body.indexOf('end', open); |
| 461 | 472 | expect(open).toBeGreaterThan(-1); |
| 462 | 473 | expect(close).toBeGreaterThan(open); |
| test('the commit is the frame, not another arrow inside it', () => { | ||
| 474 | 485 | test('a query inside a frame does not repeat the frame\'s label', () => { |
| 475 | 486 | // split on the whole line: "Backend" contains "end" |
| 476 | 487 | const inside = spansToPuml(inTransaction, 'tx', STATIC) |
| 477 | - .split('group tx\n')[1].split('\nend\n')[0]; | |
| 488 | + .split(`${COMMITTED_TX}\n`)[1].split('\nend\n')[0]; | |
| 478 | 489 | expect(inside).not.toContain('OwnerRepository.findById'); |
| 479 | 490 | expect(inside).toContain('select owners'); |
| 480 | 491 | }); |
| test('a handler-level transaction frames its body, not the request', () => { | ||
| 511 | 522 | (l) => l.startsWith('Browser -> Backend:') && l.includes('POST /api/visits')); |
| 512 | 523 | expect(request).toBeGreaterThan(-1); |
| 513 | 524 | expect(body).toContain('Backend --> Browser: 201'); |
| 514 | - const open = body.indexOf('group tx'); | |
| 525 | + const open = body.indexOf(COMMITTED_TX); | |
| 515 | 526 | const close = body.indexOf('end', open); |
| 516 | 527 | expect(open).toBeGreaterThan(request); |
| 517 | 528 | expect(body.slice(open, close).filter((l) => l.startsWith('Backend -> DB:'))).toHaveLength(2); |
| test('a service-level transaction is a frame inside the call that opened it', () | ||
| 538 | 549 | const call = body.indexOf('Backend -> Backend: VisitService.book'); |
| 539 | 550 | expect(call).toBeGreaterThan(-1); |
| 540 | 551 | expect(body[call + 1]).toBe('activate Backend'); |
| 541 | - const open = body.indexOf('group tx', call); | |
| 552 | + const open = body.indexOf(COMMITTED_TX, call); | |
| 542 | 553 | expect(open).toBeGreaterThan(call); |
| 543 | 554 | const close = body.indexOf('end', open); |
| 544 | 555 | expect(body.slice(open, close).filter((l) => l.startsWith('Backend -> DB:'))).toHaveLength(2); |
| test('a service-level transaction is a frame inside the call that opened it', () | ||
| 547 | 558 | // The frame's job is to show an extent. Whatever opened it is named by the call the frame |
| 548 | 559 | // sits inside, so a longer label would only repeat the line above it. |
| 549 | 560 | test('the frame is called tx, wherever it was opened', () => { |
| 550 | - expect(spansToPuml(onController, 'tx', STATIC)).toContain('group tx\n'); | |
| 551 | - expect(spansToPuml(onService, 'tx', STATIC)).toContain('group tx\n'); | |
| 561 | + expect(spansToPuml(onController, 'tx', STATIC)).toContain(`${COMMITTED_TX}\n`); | |
| 562 | + expect(spansToPuml(onService, 'tx', STATIC)).toContain(`${COMMITTED_TX}\n`); | |
| 552 | 563 | expect(spansToPuml(onService, 'tx', STATIC)).not.toContain('transaction ·'); |
| 553 | 564 | }); |
| 554 | 565 | |
| 566 | +// ── How the transaction ended ─────────────────────────────────────────────────── | |
| 567 | +// The agent draws a commit as a span and a rollback as nothing, so the outcome comes from | |
| 568 | +// the backend's TransactionOutcomeTracing, stamped on the span that opened the transaction. | |
| 569 | +// A trace from before that existed still has its commit span, which says "committed". | |
| 570 | + | |
| 571 | +const outcome = (spans: NormSpan[], value: string): NormSpan[] => spans | |
| 572 | + .filter((s) => s.name !== 'Transaction.commit') | |
| 573 | + .map((s) => (s.spanId === 't2' | |
| 574 | + ? {...s, attributes: {...s.attributes, 'transaction.outcome': value}} : s)); | |
| 575 | + | |
| 576 | +test('a committed transaction says so beside its tab, in grey', () => { | |
| 577 | + const puml = spansToPuml(outcome(inTransaction, 'committed'), 'tx', STATIC); | |
| 578 | + expect(puml).toContain(`${COMMITTED_TX}\n`); | |
| 579 | + // grey from the header, not an inline <color>: that splits the label into three runs | |
| 580 | + expect(puml).toContain('skinparam sequenceGroupFontColor #888888\n'); | |
| 581 | +}); | |
| 582 | + | |
| 583 | +test('a rolled-back transaction is framed too, and says so in amber', () => { | |
| 584 | + const body = spansToPuml(outcome(inTransaction, 'rolled back'), 'tx', STATIC) | |
| 585 | + .split('\n').map((l) => l.trim()); | |
| 586 | + const open = body.indexOf('group tx [<color:#B26A00>rolled back</color>]'); | |
| 587 | + expect(open).toBeGreaterThan(-1); | |
| 588 | + expect(body.slice(open, body.indexOf('end', open)) | |
| 589 | + .filter((l) => l.startsWith('Backend -> DB:'))).toHaveLength(1); | |
| 590 | +}); | |
| 591 | + | |
| 592 | +test('the stamped outcome wins over the commit span', () => { | |
| 593 | + const failed = inTransaction.map((s) => (s.spanId === 't2' | |
| 594 | + ? {...s, attributes: {'transaction.outcome': 'commit failed'}} : s)); | |
| 595 | + const puml = spansToPuml(failed, 'tx', STATIC); | |
| 596 | + expect(puml).toContain('group tx [<color:#B26A00>commit failed</color>]'); | |
| 597 | + expect(puml).not.toContain(COMMITTED_TX); | |
| 598 | +}); | |
| 599 | + | |
| 555 | 600 | // ── A @SpringBootTest drives the very same renderer ───────────────────────────── |
| 556 | 601 | // Nothing in a trace otherwise separates the test from the code it drives: a MockMvc |
| 557 | 602 | // call and the controller it reaches run in one JVM under one `service.name`. The test |
| test('a crossing arrow with nothing to reveal links to the method it was opened | ||
| 719 | 764 | + '{Click to open Sender.visitBooked} notify-visit-booked \u2197]]'); |
| 720 | 765 | }); |
| 721 | 766 | |
| 767 | +// ── A browser request links to the line that sent it ───────────────────────────── | |
| 768 | +// The link comes from the request's own stack, so it hangs off the browser's CLIENT span | |
| 769 | +// (genseq/call-site.ts) while the arrow is drawn from the SERVER span under it. | |
| 770 | +const SENT_FROM = 'src://petclinic-frontend/src/app/a.component.ts:22' | |
| 771 | + + '{Click to open the line that sent this request: a.component.ts:22}'; | |
| 772 | + | |
| 773 | +function browserRequest(browserAttributes: Record<string, string>): NormSpan[] { | |
| 774 | + return [ | |
| 775 | + {traceId: 'c', spanId: 'c-xhr', parentSpanId: '', name: 'POST', kind: 'CLIENT', | |
| 776 | + serviceName: 'petclinic-frontend', startNano: 1_000 * 1e6, attributes: browserAttributes}, | |
| 777 | + {traceId: 'c', spanId: 'c-server', parentSpanId: 'c-xhr', name: 'POST /api/visits', | |
| 778 | + kind: 'SERVER', serviceName: 'petclinic-backend', startNano: 1_100 * 1e6, | |
| 779 | + attributes: {'http.status_code': '201'}}, | |
| 780 | + ]; | |
| 781 | +} | |
| 782 | + | |
| 783 | +const located = (span: NormSpan) => (span.spanId === 'c-xhr' ? SENT_FROM : undefined); | |
| 784 | +const CALL_SITE = {'genseq.call-site': '[{"file":"src/app/a.component.ts","line":22}]'}; | |
| 785 | +const REQUEST = '{Click for the request as it was sent}'; | |
| 786 | + | |
| 787 | +test('a baked-in browser request links its words to the line that sent it', () => { | |
| 788 | + const puml = renderDiagram('src/add-visit.spec.ts', | |
| 789 | + [{title: 'books a visit', traces: [browserRequest(CALL_SITE)]}], | |
| 790 | + STATIC, new Map(), located).puml; | |
| 791 | + expect(puml).toContain(`Browser -> Backend: [[${SENT_FROM} POST /api/visits ↗]]\n`); | |
| 792 | +}); | |
| 793 | + | |
| 794 | +// The words open the request on every HTTP arrow now (Victor, 10 Nov 2026); a request whose | |
| 795 | +// frontend method is unknown has no call arriving from the left to carry the line that sent | |
| 796 | +// it, so that line is a bare ↗ beside them. | |
| 797 | +test('a request with no known frontend method keeps the line that sent it beside its words', () => { | |
| 798 | + const {puml, details} = renderDiagram('src/add-visit.spec.ts', | |
| 799 | + [{title: 'books a visit', traces: [browserRequest({...CALL_SITE, 'http.request.body': '{"a":1}'})]}], | |
| 800 | + undefined, new Map(), located); | |
| 801 | + const [id] = Object.keys(details.details); | |
| 802 | + expect(puml).toContain(`Browser -> Backend: [[genseq://${id}${REQUEST} POST /api/visits ⊕]] ` | |
| 803 | + + `[[${SENT_FROM} ↗]]\n`); | |
| 804 | +}); | |
| 805 | + | |
| 806 | +test('a request with no known line opens its request and nothing else', () => { | |
| 807 | + const {puml, details} = renderDiagram('src/add-visit.spec.ts', | |
| 808 | + [{title: 'books a visit', traces: [browserRequest({'http.request.body': '{"a":1}'})]}], | |
| 809 | + undefined, new Map(), located); | |
| 810 | + const [id] = Object.keys(details.details); | |
| 811 | + expect(puml).toContain(`Browser -> Backend: [[genseq://${id}${REQUEST} POST /api/visits ⊕]]\n`); | |
| 812 | + expect(puml).not.toContain('src://'); | |
| 813 | +}); | |
| 814 | + | |
| 815 | +test('a call site the checkout could not confirm leaves the arrow plain', () => { | |
| 816 | + const puml = renderDiagram('src/add-visit.spec.ts', | |
| 817 | + [{title: 'books a visit', traces: [browserRequest(CALL_SITE)]}], | |
| 818 | + STATIC, new Map(), () => undefined).puml; | |
| 819 | + expect(puml).toContain('Browser -> Backend: POST /api/visits\n'); | |
| 820 | +}); | |
| 821 | + | |
| 822 | +// ── A browser request is drawn as the frontend method it went through ──────────── | |
| 823 | +// A call arriving on the Browser from the left edge names the service method | |
| 824 | +// (genseq/client-method.ts) and opens the line that sent the request; the request leaves from | |
| 825 | +// inside its activation, its words open the request as it was sent, and the controller method | |
| 826 | +// it landed on (genseq/controller-location.ts) is drawn where SEQ_HANDLER says. | |
| 827 | +const LANDED_ON = 'src://petclinic-backend/src/main/java/VisitController.java:30' | |
| 828 | + + '{Click to open VisitController.addVisit}'; | |
| 829 | +const controller = (span: NormSpan) => (span.kind === 'SERVER' | |
| 830 | + ? {name: 'VisitController.addVisit', handle: LANDED_ON} : undefined); | |
| 831 | +const throughVisitService = (span: NormSpan) => | |
| 832 | + span.spanId === 'c-xhr' ? 'VisitService.addVisit' : undefined; | |
| 833 | +const CALL: DiagramOptions = {...DEFAULT_DIAGRAM_OPTIONS, handler: 'call'}; | |
| 834 | + | |
| 835 | +const drawn = (attributes: Record<string, string>, options?: DiagramOptions) => renderDiagram( | |
| 836 | + 'src/add-visit.spec.ts', [{title: 'books a visit', traces: [browserRequest(attributes)]}], | |
| 837 | + options, new Map(), located, throughVisitService, controller); | |
| 838 | + | |
| 839 | +test('a browser request arrives on the Browser from the left, its request sent from inside it', () => { | |
| 840 | + const {puml, details} = drawn(CALL_SITE); | |
| 841 | + const [id] = Object.keys(details.details); | |
| 842 | + const lines = puml.split('\n'); | |
| 843 | + const at = lines.indexOf(`[-> Browser: [[${SENT_FROM} VisitService.addVisit ↗]]`); | |
| 844 | + expect(at).toBeGreaterThan(0); | |
| 845 | + expect(lines.slice(at + 1)).toEqual([ | |
| 846 | + 'activate Browser', | |
| 847 | + `Browser -> Backend: [[genseq://${id}${REQUEST} POST /api/visits ⊕]]`, | |
| 848 | + 'Backend --> Browser: 201', | |
| 849 | + 'deactivate Browser', | |
| 850 | + '@enduml', | |
| 851 | + '', | |
| 852 | + ]); | |
| 853 | + expect(puml).not.toContain('Browser -> Browser'); | |
| 854 | +}); | |
| 855 | + | |
| 856 | +// The panel opens on a GET with no body as on a POST with one: it is the request's URL and | |
| 857 | +// headers a reader came for, and what the review page's "Open in Bruno" sends again. | |
| 858 | +test('every request arrow opens its request, a GET with no body too', () => { | |
| 859 | + const headers = JSON.stringify({Accept: 'application/json, text/plain, */*'}); | |
| 860 | + const spans: NormSpan[] = [ | |
| 861 | + {traceId: 'g', spanId: 'g-xhr', parentSpanId: '', name: 'GET', kind: 'CLIENT', | |
| 862 | + serviceName: 'petclinic-frontend', startNano: 1e9, attributes: { | |
| 863 | + 'http.method': 'GET', 'http.url': 'http://127.0.0.1:8080/api/owners/1', | |
| 864 | + 'genseq.request-headers': headers, | |
| 865 | + }}, | |
| 866 | + {traceId: 'g', spanId: 'g-server', parentSpanId: 'g-xhr', name: 'GET /api/owners/{ownerId}', | |
| 867 | + kind: 'SERVER', serviceName: 'petclinic-backend', startNano: 1.1e9, | |
| 868 | + attributes: {'http.response.status_code': '200'}}, | |
| 869 | + ]; | |
| 870 | + const {puml, details} = renderDiagram('src/owners.spec.ts', [{title: 'x', traces: [spans]}]); | |
| 871 | + const entry = detailOn(lineWith(puml, 'Browser -> Backend:'), details.details); | |
| 872 | + expect(entry).toEqual({ | |
| 873 | + title: 'Browser → Backend: GET /api/owners/{ownerId}', | |
| 874 | + steps: [{label: 'request', | |
| 875 | + text: 'GET http://127.0.0.1:8080/api/owners/1\nAccept: application/json, text/plain, */*'}], | |
| 876 | + request: {method: 'GET', url: 'http://127.0.0.1:8080/api/owners/1', | |
| 877 | + headers: {Accept: 'application/json, text/plain, */*'}}, | |
| 878 | + }); | |
| 879 | +}); | |
| 880 | + | |
| 881 | +test('a request’s body is the raw payload for a replay and pretty-printed for a reader', () => { | |
| 882 | + const {puml, details} = drawn({...CALL_SITE, 'http.method': 'POST', | |
| 883 | + 'http.url': 'http://127.0.0.1:8080/api/visits', 'http.request.body': '{"a":1}'}); | |
| 884 | + const entry = detailOn(lineWith(puml, 'Browser -> Backend:'), details.details) as any; | |
| 885 | + expect(entry.steps).toEqual([ | |
| 886 | + {label: 'request', text: 'POST http://127.0.0.1:8080/api/visits\n\n{\n "a": 1\n}'}]); | |
| 887 | + expect(entry.request).toEqual({method: 'POST', url: 'http://127.0.0.1:8080/api/visits', body: '{"a":1}'}); | |
| 888 | +}); | |
| 889 | + | |
| 890 | +test('SEQ_HANDLER=call draws the controller as a call that everything the request did nests in', () => { | |
| 891 | + const {puml, details} = drawn(CALL_SITE, CALL); | |
| 892 | + const [id] = Object.keys(details.details); | |
| 893 | + const lines = puml.split('\n'); | |
| 894 | + const at = lines.indexOf(`Browser -> Backend: [[genseq://${id}${REQUEST} POST /api/visits ⊕]]`); | |
| 895 | + expect(at).toBeGreaterThan(0); | |
| 896 | + expect(lines.slice(at + 1, at + 6)).toEqual([ | |
| 897 | + 'activate Backend', | |
| 898 | + `Backend -> Backend: [[${LANDED_ON} VisitController.addVisit ↗]]`, | |
| 899 | + 'Backend --> Browser: 201', | |
| 900 | + 'deactivate Backend', | |
| 901 | + 'deactivate Browser', | |
| 902 | + ]); | |
| 903 | + expect(puml).not.toContain('→ VisitController'); | |
| 904 | +}); | |
| 905 | + | |
| 906 | +// Any service's controller, and with the request's own work inside its activation — the | |
| 907 | +// transaction a @Transactional controller opened included. | |
| 908 | +test('the controller call nests the work of the request, on whichever service it landed', () => { | |
| 909 | + const span = (spanId: string, parentSpanId: string, name: string, kind: NormSpan['kind'], | |
| 910 | + serviceName: string, attributes: Record<string, string> = {}): NormSpan => ({ | |
| 911 | + traceId: 'n', spanId, parentSpanId, name, kind, serviceName, | |
| 912 | + startNano: Number(spanId.slice(1)) * 1e6, attributes, | |
| 913 | + }); | |
| 914 | + const spans = [ | |
| 915 | + span('n1', '', 'POST /api/visits', 'SERVER', 'petclinic-backend', {'http.status_code': '201'}), | |
| 916 | + span('n2', 'n1', 'POST', 'CLIENT', 'petclinic-backend', | |
| 917 | + {'url.full': 'http://notification:8090/api/notifications/visit-booked'}), | |
| 918 | + span('n3', 'n2', 'POST /api/notifications/visit-booked', 'SERVER', 'notification-service', | |
| 919 | + {'http.response.status_code': '202', 'transaction.outcome': 'committed'}), | |
| 920 | + span('n4', 'n3', 'send-sms', 'INTERNAL', 'notification-service'), | |
| 921 | + ]; | |
| 922 | + const handled = (s: NormSpan) => (s.spanId === 'n3' | |
| 923 | + ? {name: 'NotificationController.visitBooked', handle: 'src://n/C.java:9{Click}'} : undefined); | |
| 924 | + const {puml, details} = renderDiagram('x', [{title: 'x', traces: [spans]}], CALL, | |
| 925 | + new Map(), undefined, undefined, handled); | |
| 926 | + const lines = puml.split('\n'); | |
| 927 | + const at = lines.findIndex((l) => l.startsWith('Backend -> NotificationService:')); | |
| 928 | + expect(lines.slice(at + 1, at + 9)).toEqual([ | |
| 929 | + 'activate NotificationService', | |
| 930 | + 'NotificationService -> NotificationService: [[src://n/C.java:9{Click} ' | |
| 931 | + + 'NotificationController.visitBooked ↗]]', | |
| 932 | + 'activate NotificationService', | |
| 933 | + 'group tx [committed]', | |
| 934 | + 'NotificationService -> NotificationService: send-sms', | |
| 935 | + 'end', | |
| 936 | + 'deactivate NotificationService', | |
| 937 | + 'NotificationService --> Backend: 202', | |
| 938 | + ]); | |
| 939 | + expect(lines[at + 9]).toBe('deactivate NotificationService'); | |
| 940 | + expect(detailOn(lines[at], details.details).request!.url) | |
| 941 | + .toBe('http://notification:8090/api/notifications/visit-booked'); | |
| 942 | +}); | |
| 943 | + | |
| 944 | +test('a controller call with nothing inside it is a bare arrow, no bar of its own', () => { | |
| 945 | + const spans: NormSpan[] = [ | |
| 946 | + {traceId: 'e', spanId: 'e-test', parentSpanId: '', name: 'when', kind: 'INTERNAL', | |
| 947 | + serviceName: 'petclinic-backend', startNano: 1e9, attributes: {'genseq.participant': 'Test'}}, | |
| 948 | + {traceId: 'e', spanId: 'e-server', parentSpanId: 'e-test', name: 'POST /api/visits', | |
| 949 | + kind: 'SERVER', serviceName: 'petclinic-backend', startNano: 1.1e9, | |
| 950 | + attributes: {'http.status_code': '201'}}, | |
| 951 | + ]; | |
| 952 | + const {puml} = renderDiagram('x', [{title: 'x', traces: [spans]}], CALL, | |
| 953 | + new Map(), undefined, undefined, controller); | |
| 954 | + expect(puml).toContain('activate Backend\n' | |
| 955 | + + `Backend -> Backend: [[${LANDED_ON} VisitController.addVisit \u2197]]\n` | |
| 956 | + + 'Backend --> Test: 201\ndeactivate Backend\n'); | |
| 957 | + expect(puml.match(/^activate Backend$/gm)).toHaveLength(1); | |
| 958 | +}); | |
| 959 | + | |
| 960 | +test('a request whose controller is unknown has neither a third line nor a call', () => { | |
| 961 | + for (const options of [DEFAULT_DIAGRAM_OPTIONS, CALL]) { | |
| 962 | + const {puml} = renderDiagram('src/add-visit.spec.ts', | |
| 963 | + [{title: 'books a visit', traces: [browserRequest(CALL_SITE)]}], | |
| 964 | + options, new Map(), located, throughVisitService); | |
| 965 | + expect(puml).not.toContain('Backend -> Backend'); | |
| 966 | + expect(lineWith(puml, 'Browser -> Backend:')).not.toContain('\\n[['); | |
| 967 | + } | |
| 968 | +}); | |
| 969 | + | |
| 970 | +test('a baked-in request has no panel, and names its controller only as a call', () => { | |
| 971 | + const panel = drawn(CALL_SITE, STATIC).puml; | |
| 972 | + expect(panel).toContain('Browser -> Backend: POST /api/visits\n'); | |
| 973 | + expect(panel).not.toContain('genseq://'); | |
| 974 | + expect(panel).not.toContain('VisitController'); | |
| 975 | + const call = drawn(CALL_SITE, {...STATIC, handler: 'call'}).puml; | |
| 976 | + expect(call).toContain(`Browser -> Backend: POST /api/visits\nactivate Backend\n` | |
| 977 | + + `Backend -> Backend: [[${LANDED_ON} VisitController.addVisit ↗]]\n`); | |
| 978 | +}); | |
| 979 | + | |
| 722 | 980 | // ── A driver call with no statement is not a query ─────────────────────────────── |
| 723 | 981 | // The JDBC instrumentation times every call into the driver, so borrowing or validating a |
| 724 | 982 | // pooled connection arrives as a CLIENT span with the whole database semconv on it and |
| test('a DB span with an empty statement draws nothing at all', () => { | ||
| 760 | 1018 | expect(withStatement).toContain( |
| 761 | 1019 | 'Backend -> DB: select owners\\nSELECT o1_0.id\\nFROM owners o1_0'); |
| 762 | 1020 | }); |
| 1021 | + | |
| 1022 | +// A lifeline that is a module, not a service, says so above its name. The guillemets go | |
| 1023 | +// into the display name, behind `as`: the review page's parsers read `participant X` and | |
| 1024 | +// `participant "…" as X`, and nothing after either. | |
| 1025 | +test('a lifeline declared with a stereotype names its kind above it', () => { | |
| 1026 | + const span = (spanId: string, parentSpanId: string, name: string, | |
| 1027 | + attributes: Record<string, string>): NormSpan => ({ | |
| 1028 | + traceId: 'm', spanId, parentSpanId, name, kind: parentSpanId ? 'INTERNAL' : 'SERVER', | |
| 1029 | + serviceName: 'petclinic-backend', startNano: Number(spanId.slice(1)) * 1e6, attributes, | |
| 1030 | + }); | |
| 1031 | + const spans = [ | |
| 1032 | + span('m1', '', 'POST /api/visits', {'http.status_code': '201'}), | |
| 1033 | + span('m2', 'm1', 'PhoneNumbers.normalize', | |
| 1034 | + {'genseq.participant': 'Commons', 'genseq.stereotype': 'module'}), | |
| 1035 | + span('m3', 'm2', 'NotificationServiceClient.homeDialCode', {}), | |
| 1036 | + ]; | |
| 1037 | + const puml = renderPuml('add-visit.spec.ts', [{title: 'x', traces: [spans]}], STATIC); | |
| 1038 | + | |
| 1039 | + expect(puml).toContain('participant "«module»\\nCommons" as Commons'); | |
| 1040 | + expect(puml).toContain('participant Backend\n'); | |
| 1041 | + expect(puml).toContain('Backend -> Commons: PhoneNumbers.normalize'); | |
| 1042 | + expect(puml).toContain('Commons -> Backend: NotificationServiceClient.homeDialCode'); | |
| 1043 | +}); |
| 1 | 1 | import {formatOriginLabel, formatSqlDetail, formatSqlLabel, splitOrigin, summarizeStatement} from './sql-label'; |
| 2 | 2 | import {formatJsonDetail, jsonNote} from './json-label'; |
| 3 | 3 | import {DEFAULT_DIAGRAM_OPTIONS, DiagramOptions} from './options'; |
| 4 | -import {DetailCollector, DetailIndex, DetailStep} from './detail-index'; | |
| 4 | +import {DetailCollector, DetailEntry, DetailIndex, DetailStep} from './detail-index'; | |
| 5 | 5 | import {OperationNames, defaultOperations, operationNameOf} from './openapi-operations'; |
| 6 | 6 | import {linkedSectionTitle} from './test-location'; |
| 7 | -import {linkedMethodLabel} from './code-location'; | |
| 7 | +import {METHOD_LINK_MARKER, linkedMethodLabel} from './code-location'; | |
| 8 | +import {CALL_SITE_ATTRIBUTE} from './call-site'; | |
| 9 | +import {recordedRequest, requestMessage} from './http-request'; | |
| 8 | 10 | |
| 9 | 11 | export interface NormSpan { |
| 10 | 12 | traceId: string; |
| const DB_NAME_RE = /^(SELECT|INSERT|UPDATE|DELETE|MERGE)\b/i; | ||
| 73 | 75 | // Set by petclinic-backend's genseq/Steps.java; the two are one contract. |
| 74 | 76 | export const PARTICIPANT_ATTRIBUTE = 'genseq.participant'; |
| 75 | 77 | |
| 78 | +/** What kind of thing a declared lifeline is — `module`, for a jar linked into the services. */ | |
| 79 | +export const STEREOTYPE_ATTRIBUTE = 'genseq.stereotype'; | |
| 80 | + | |
| 76 | 81 | /** The lifeline a @SpringBootTest's own `given`/`when`/`then` marks are drawn on. */ |
| 77 | 82 | export const TEST_PARTICIPANT = 'Test'; |
| 78 | 83 | |
| function arrowLabel( | ||
| 204 | 209 | const MARKER_SCHEME = 'genseq://'; |
| 205 | 210 | const MARKER_GLYPH = '⊕'; |
| 206 | 211 | |
| 207 | -/** `label`, wrapped in the link that reveals `steps` — or bare, when there is nothing to reveal. */ | |
| 212 | +/** `label`, wrapped in the link that reveals `entry` — or bare, when there is nothing to reveal. */ | |
| 208 | 213 | function linkLabel( |
| 209 | - label: string, collector: DetailCollector, title: string, steps: DetailStep[], tooltip: string, | |
| 214 | + label: string, collector: DetailCollector, entry: DetailEntry, tooltip: string, | |
| 210 | 215 | ): string { |
| 211 | - if (steps.length === 0) return label; | |
| 212 | - const id = collector.add({title, steps}); | |
| 216 | + if (entry.steps.length === 0) return label; | |
| 217 | + const id = collector.add(entry); | |
| 213 | 218 | return `[[${MARKER_SCHEME}${id}{${tooltip}} ${label} ${MARKER_GLYPH}]]`; |
| 214 | 219 | } |
| 215 | 220 | |
| 221 | +/** The `⊕` on its own, for a label whose words already link somewhere else. */ | |
| 222 | +function linkMarker(collector: DetailCollector, entry: DetailEntry, tooltip: string): string { | |
| 223 | + return `[[${MARKER_SCHEME}${collector.add(entry)}{${tooltip}} ${MARKER_GLYPH}]]`; | |
| 224 | +} | |
| 225 | + | |
| 216 | 226 | const SQL_TOOLTIP = 'Click for the statement behind this call'; |
| 217 | 227 | const BODY_TOOLTIP = 'Click for this call’s JSON body'; |
| 228 | +const REQUEST_TOOLTIP = 'Click for the request as it was sent'; | |
| 218 | 229 | |
| 219 | 230 | /** |
| 220 | 231 | * One step: the statement as Hibernate sent it. The bound values ride along as the |
| const REPOSITORY_SPAN_RE = /Repository\.\w+$/; | ||
| 285 | 296 | // transaction ended somewhere above, and leaves the reader to guess how far up. |
| 286 | 297 | const TRANSACTION_COMMIT = 'Transaction.commit'; |
| 287 | 298 | |
| 299 | +// How the transaction ended, stamped by the backend's TransactionOutcomeTracing on the span | |
| 300 | +// that opened it: `committed`, `rolled back` or `commit failed`. The agent draws a commit as | |
| 301 | +// a span of its own (above) but a rollback as nothing at all, so a transaction that rolled | |
| 302 | +// back used to get no frame, and a frame could only ever have meant "committed". A trace | |
| 303 | +// recorded before the attribute existed still has its commit span, and that says the same. | |
| 304 | +export const TRANSACTION_OUTCOME_ATTRIBUTE = 'transaction.outcome'; | |
| 305 | +const COMMITTED = 'committed'; | |
| 306 | + | |
| 307 | +// The frame's tab says just `tx` — whatever opened it is named by the call it sits inside, | |
| 308 | +// so a longer tab would only repeat that. How it ended goes *beside* the tab: PlantUML draws | |
| 309 | +// a group's `[bracketed]` tail as a second label to the right of it, in the header's grey | |
| 310 | +// (GROUP_TAIL_STYLE) — the expected case, read as a quiet status. Anything but a commit is | |
| 311 | +// amber, the case a reader must not skim past; it reads on a light and a dark page as it is. | |
| 312 | +function transactionHeader(outcome: string): string { | |
| 313 | + return outcome === COMMITTED | |
| 314 | + ? `group tx [${outcome}]` | |
| 315 | + : `group tx [<color:#B26A00>${outcome}</color>]`; | |
| 316 | +} | |
| 317 | + | |
| 318 | +// The tail's style, for every group: grey and plain where the `tx` tab stays bold. In the | |
| 319 | +// header rather than as an inline <color>, which splits `[committed]` into three text runs — | |
| 320 | +// and the review page, redrawing labels in its own font, then opens a gap before the `]`. | |
| 321 | +// #888888 is that page's muted grey, which it re-themes for dark mode. | |
| 322 | +const GROUP_TAIL_STYLE = [ | |
| 323 | + 'skinparam sequenceGroupFontColor #888888', | |
| 324 | + 'skinparam sequenceGroupFontStyle plain', | |
| 325 | +]; | |
| 326 | + | |
| 288 | 327 | /** The opt-in a reader will find in the source this diagram was drawn from. */ |
| 289 | 328 | function optInOf(source: string): string { |
| 290 | 329 | return source.endsWith('.java') ? '@GenerateSequence' : '@generate_sequence'; |
| function qualifiedTitle(title: string, source: string): string { | ||
| 318 | 357 | return `${cls}: ${title}`; |
| 319 | 358 | } |
| 320 | 359 | |
| 360 | +/** | |
| 361 | + * The picture's heading says it is a test. Without the word, `Add a visit to an existing | |
| 362 | + * pet from the owner detail page` reads like a feature's name, and a reader landing on the | |
| 363 | + * picture (a review page, a slide) takes the arrows for how the app always behaves rather | |
| 364 | + * than for one recorded run of one test (12 Oct 2026, Victor). | |
| 365 | + */ | |
| 366 | +export const TEST_TITLE_PREFIX = 'Test: '; | |
| 367 | + | |
| 321 | 368 | // Left to right is the direction a call travels. Browser and Test never appear together: |
| 322 | 369 | // one is a browser suite's lifeline, the other a @SpringBootTest's, and each drives the |
| 323 | 370 | // backend from the same place on the page. |
| export function pumlName(participant: string): string { | ||
| 339 | 386 | return BARE_IDENTIFIER.test(participant) ? participant : `"${participant}"`; |
| 340 | 387 | } |
| 341 | 388 | |
| 389 | +function stereotypesOf(scenarios: DiagramScenario[]): Map<string, string> { | |
| 390 | + const kinds = new Map<string, string>(); | |
| 391 | + for (const span of scenarios.flatMap((s) => s.traces.flat())) { | |
| 392 | + const participant = span.attributes[PARTICIPANT_ATTRIBUTE]?.trim(); | |
| 393 | + const kind = span.attributes[STEREOTYPE_ATTRIBUTE]?.trim(); | |
| 394 | + if (participant && kind) kinds.set(participant, kind); | |
| 395 | + } | |
| 396 | + return kinds; | |
| 397 | +} | |
| 398 | + | |
| 399 | +// «kind» goes into the display name rather than as PlantUML's own `<<kind>>`: the review | |
| 400 | +// page's parsers read `participant X` and `participant "…" as X`, and nothing after either. | |
| 401 | +function participantLine(participant: string, kind: string | undefined): string { | |
| 402 | + return kind | |
| 403 | + ? `participant "«${kind}»\\n${participant}" as ${pumlName(participant)}` | |
| 404 | + : `participant ${pumlName(participant)}`; | |
| 405 | +} | |
| 406 | + | |
| 342 | 407 | function orderedParticipants(present: Set<string>): string[] { |
| 343 | 408 | const ranked = PARTICIPANT_ORDER.filter((p) => present.has(p)); |
| 344 | 409 | const rest = [...present].filter((p) => !PARTICIPANT_ORDER.includes(p)).sort(); |
| function orderedParticipants(present: Set<string>): string[] { | ||
| 348 | 413 | function emitTrace( |
| 349 | 414 | spans: NormSpan[], lines: string[], present: Set<string>, options: DiagramOptions, |
| 350 | 415 | collector: DetailCollector, operations: OperationNames, methodLinks: MethodLinks, |
| 416 | + clientMethods: ClientMethods, controllers: Controllers, | |
| 351 | 417 | ): void { |
| 352 | 418 | const byId = new Map(spans.map((s) => [s.spanId, s])); |
| 353 | 419 | // Indexed once: filtering the whole span array per span made an N+1-heavy trace |
| function emitTrace( | ||
| 364 | 430 | const childrenOf = (id: string) => childrenByParent.get(id) ?? []; |
| 365 | 431 | |
| 366 | 432 | /** |
| 367 | - * A span that opened a transaction: the interceptor committed inside it. | |
| 433 | + * How the transaction this span opened ended, or undefined when it opened none. | |
| 368 | 434 | * |
| 369 | - * In this codebase that is every Spring Data repository method, because nothing above | |
| 370 | - * them is `@Transactional` — so each call gets its own transaction and its own session, | |
| 371 | - * which is a fact about the design worth being able to see. | |
| 435 | + * In this codebase the opener is every Spring Data repository method, because nothing | |
| 436 | + * above them is `@Transactional` — so each call gets its own transaction and its own | |
| 437 | + * session, which is a fact about the design worth being able to see. | |
| 372 | 438 | */ |
| 373 | - const opensTransaction = (span: NormSpan): boolean => | |
| 374 | - childrenOf(span.spanId).some((c) => c.name === TRANSACTION_COMMIT); | |
| 375 | - | |
| 376 | - // Just `tx`. The frame's job is to show an *extent*, and whatever opened it is named | |
| 377 | - // by the call the frame sits inside — a longer label only repeats that. | |
| 378 | - const TRANSACTION_LABEL = 'tx'; | |
| 439 | + const transactionOutcome = (span: NormSpan): string | undefined => | |
| 440 | + span.attributes[TRANSACTION_OUTCOME_ATTRIBUTE]?.trim() | |
| 441 | + || (childrenOf(span.spanId).some((c) => c.name === TRANSACTION_COMMIT) | |
| 442 | + ? COMMITTED : undefined); | |
| 443 | + const opensTransaction = (span: NormSpan): boolean => transactionOutcome(span) !== undefined; | |
| 379 | 444 | |
| 380 | 445 | const nearestAncestor = (span: NormSpan, matches: RegExp): NormSpan | undefined => { |
| 381 | 446 | for (let up = span.parentSpanId ? byId.get(span.parentSpanId) : undefined; |
| function emitTrace( | ||
| 398 | 463 | }; |
| 399 | 464 | |
| 400 | 465 | |
| 466 | + /** | |
| 467 | + * The label of the arrow into a SERVER span: its words open the request as it was sent — the | |
| 468 | + * concrete URL, the headers, the body (genseq/http-request.ts) — for every HTTP arrow, with a | |
| 469 | + * body or without, as the inter-service arrows' words open their payload (Victor, 10 Nov 2026). | |
| 470 | + * The panel keeps the title `Browser → Backend: GET /api/owners/{ownerId}`: the review page | |
| 471 | + * reads the route back out of it to name the controller in the panel's head. | |
| 472 | + * | |
| 473 | + * Under `handler: 'panel'` the controller is named only in that panel's head; under `call` | |
| 474 | + * it is a self-call on the receiving lifeline (below). | |
| 475 | + * | |
| 476 | + * A browser request whose frontend method is unknown has no call arriving from the left to | |
| 477 | + * carry the line that sent it, so that line hangs off the words as a bare `↗` of its own. | |
| 478 | + */ | |
| 479 | + const requestLabel = ( | |
| 480 | + span: NormSpan, parent: NormSpan | undefined, pp: string, p: string, | |
| 481 | + landedOn: Controller | undefined, | |
| 482 | + ): string => { | |
| 483 | + const text = arrowLabel(span, p, options, operations); | |
| 484 | + const drawnAsCall = parent?.kind === 'CLIENT' && clientMethods(parent) !== undefined; | |
| 485 | + const sentFrom = !drawnAsCall && parent?.attributes[CALL_SITE_ATTRIBUTE] | |
| 486 | + ? methodLinks(parent) : undefined; | |
| 487 | + let words: string; | |
| 488 | + if (options.interactive) { | |
| 489 | + const body = options.httpBodies ? bodyOf(span, parent, 'http.request.body') : undefined; | |
| 490 | + const request = recordedRequest(span, parent, body); | |
| 491 | + const steps = [{label: 'request', text: requestMessage(request)}]; | |
| 492 | + words = linkLabel(text, collector, {title: `${pp} → ${p}: ${span.name}`, steps, request}, REQUEST_TOOLTIP); | |
| 493 | + if (sentFrom) words += ` [[${sentFrom} ${METHOD_LINK_MARKER}]]`; | |
| 494 | + } else { | |
| 495 | + words = linkedMethodLabel(text, sentFrom); | |
| 496 | + } | |
| 497 | + return words; | |
| 498 | + }; | |
| 499 | + | |
| 401 | 500 | const walk = (span: NormSpan, out: string[], parentParticipant?: string): void => { |
| 402 | 501 | // Whatever the driver does inside a query is the database's business: drawing a |
| 403 | 502 | // child of a DB span would put an arrow *out* of the DB lifeline, as if the |
| function emitTrace( | ||
| 438 | 537 | // transaction is opened by the request handler itself — `@Transactional` on a |
| 439 | 538 | // controller method — framing the span would swallow the request and the response |
| 440 | 539 | // with it, and the picture would lose the call it is about. |
| 441 | - const body = opensTransaction(span) && inner.length > 0 | |
| 442 | - ? [`group ${TRANSACTION_LABEL}`, ...inner, 'end'] | |
| 443 | - : inner; | |
| 540 | + const outcome = inner.length > 0 ? transactionOutcome(span) : undefined; | |
| 541 | + const body = outcome ? [transactionHeader(outcome), ...inner, 'end'] : inner; | |
| 444 | 542 | |
| 445 | 543 | if (!crossing && !selfCustom) { |
| 446 | 544 | present.add(p); |
| 447 | - out.push(...body); | |
| 545 | + // A browser request is drawn as a call too: the frontend method it went through | |
| 546 | + // (`OwnerService.getOwnerById`, genseq/client-method.ts), and the request leaving from | |
| 547 | + // inside its activation — the shape a repository call has on the Backend. Its words open | |
| 548 | + // the line that sent the request, in this run. | |
| 549 | + // | |
| 550 | + // The call arrives from the left edge, not as a self-call (Victor, 10 Nov 2026): nothing | |
| 551 | + // on the Browser calls its own service, the user's click does, and that is off the page. | |
| 552 | + const via = span.kind === 'CLIENT' && inner.length > 0 ? clientMethods(span) : undefined; | |
| 553 | + if (via) { | |
| 554 | + out.push(`[-> ${pumlName(p)}: ${linkedMethodLabel(via, methodLinks(span))}`, | |
| 555 | + `activate ${pumlName(p)}`, ...body, `deactivate ${pumlName(p)}`); | |
| 556 | + } else { | |
| 557 | + out.push(...body); | |
| 558 | + } | |
| 448 | 559 | return; |
| 449 | 560 | } |
| 450 | 561 | |
| function emitTrace( | ||
| 453 | 564 | const bodies = options.httpBodies && !options.interactive && crossing |
| 454 | 565 | ? `${pumlName(pp!)}, ${pumlName(p)}` : undefined; |
| 455 | 566 | |
| 456 | - if (crossing) { | |
| 567 | + // An HTTP request is the arrow into a SERVER span. Its words open the request itself, so | |
| 568 | + // the method it landed on — the controller, the first class of the application it reaches | |
| 569 | + // (genseq/controller-location.ts) — is drawn where `options.handler` says: a third line of | |
| 570 | + // the label, or a self-call that everything the request did nests inside. | |
| 571 | + const isRequest = crossing && span.kind === 'SERVER'; | |
| 572 | + const landedOn = isRequest ? controllers(span) : undefined; | |
| 573 | + // The self-call is the first thing inside the request's activation, and gets one of its own | |
| 574 | + // only when something ran inside it: the same rule as every other bar on the picture. | |
| 575 | + let inside = body; | |
| 576 | + if (landedOn && options.handler === 'call') { | |
| 577 | + const P = pumlName(p); | |
| 578 | + const self = `${P} -> ${P}: ${linkedMethodLabel(landedOn.name, landedOn.handle)}`; | |
| 579 | + inside = body.length > 0 ? [self, `activate ${P}`, ...body, `deactivate ${P}`] : [self]; | |
| 580 | + } | |
| 581 | + | |
| 582 | + if (isRequest) { | |
| 583 | + present.add(pp!); | |
| 584 | + present.add(p); | |
| 585 | + out.push(`${pumlName(pp!)} -> ${pumlName(p)}: ${requestLabel(span, parent, pp!, p, landedOn)}`); | |
| 586 | + if (bodies) out.push(...jsonNote(bodies, bodyOf(span, parent, 'http.request.body'))); | |
| 587 | + } else if (crossing) { | |
| 457 | 588 | present.add(pp!); |
| 458 | 589 | present.add(p); |
| 459 | 590 | const steps = p === 'DB' |
| function emitTrace( | ||
| 465 | 596 | // opened is called `SELECT petclinic.owners`. |
| 466 | 597 | const title = p === 'DB' ? text : `${pp} → ${p}: ${span.name}`; |
| 467 | 598 | const tooltip = p === 'DB' ? SQL_TOOLTIP : BODY_TOOLTIP; |
| 468 | - // PlantUML gives a message label exactly one link, so the two candidates take turns: | |
| 469 | - // the ⊕ that unfolds the SQL or the JSON body wins where there is something to unfold, | |
| 470 | - // and where there is not the slot is free for the code the span was opened on. | |
| 599 | + const entry = {title, steps}; | |
| 600 | + // The ⊕ that unfolds the SQL or the JSON body wins where there is something to unfold, | |
| 601 | + // and where there is not the words are free for the code the span was opened on. | |
| 471 | 602 | // `notify-visit-booked` is that case — a crossing @WithSpan arrow that reveals nothing, |
| 472 | 603 | // and until this it was the only kind of arrow in the picture with no way back to its method. |
| 473 | - const label = steps.length > 0 | |
| 474 | - ? linkLabel(text, collector, title, steps, tooltip) | |
| 475 | - : linkedMethodLabel(text, methodLinks(span)); | |
| 604 | + // Where there is both, the ⊕ is a second link beside the words. | |
| 605 | + const wordsTo = methodLinks(span); | |
| 606 | + const label = wordsTo && steps.length > 0 | |
| 607 | + ? `${linkedMethodLabel(text, wordsTo)} ${linkMarker(collector, entry, tooltip)}` | |
| 608 | + : steps.length > 0 | |
| 609 | + ? linkLabel(text, collector, entry, tooltip) | |
| 610 | + : linkedMethodLabel(text, wordsTo); | |
| 476 | 611 | out.push(`${pumlName(pp!)} -> ${pumlName(p)}: ${label}`); |
| 477 | 612 | if (bodies) out.push(...jsonNote(bodies, bodyOf(span, parent, 'http.request.body'))); |
| 478 | 613 | } else { |
| function emitTrace( | ||
| 482 | 617 | out.push(`${pumlName(p)} -> ${pumlName(p)}: ${linkedMethodLabel(span.name, methodLinks(span))}`); |
| 483 | 618 | } |
| 484 | 619 | |
| 485 | - if (inner.length > 0) out.push(`activate ${pumlName(p)}`); | |
| 486 | - out.push(...body); | |
| 620 | + if (inside.length > 0) out.push(`activate ${pumlName(p)}`); | |
| 621 | + out.push(...inside); | |
| 487 | 622 | // Only a meaningful return (an HTTP status) earns an arrow back. |
| 488 | 623 | const label = crossing ? returnLabel(span) : undefined; |
| 489 | 624 | if (label) { |
| 490 | 625 | const steps = bodySteps(bodyOf(span, parent, 'http.response.body'), 'response body', options); |
| 491 | 626 | out.push(`${pumlName(p)} --> ${pumlName(pp!)}: ${ |
| 492 | - linkLabel(label, collector, `${p} → ${pp}: ${label}`, steps, BODY_TOOLTIP)}`); | |
| 627 | + linkLabel(label, collector, {title: `${p} → ${pp}: ${label}`, steps}, BODY_TOOLTIP)}`); | |
| 493 | 628 | } |
| 494 | 629 | if (bodies) out.push(...jsonNote(bodies, bodyOf(span, parent, 'http.response.body'))); |
| 495 | - if (inner.length > 0) out.push(`deactivate ${pumlName(p)}`); | |
| 630 | + if (inside.length > 0) out.push(`deactivate ${pumlName(p)}`); | |
| 496 | 631 | }; |
| 497 | 632 | |
| 498 | 633 | const roots = spans |
| export type MethodLinks = (span: NormSpan, scenario?: DiagramScenario) => string | ||
| 520 | 655 | |
| 521 | 656 | const NO_METHOD_LINKS: MethodLinks = () => undefined; |
| 522 | 657 | |
| 658 | +/** | |
| 659 | + * The frontend method a browser CLIENT span's request went through, as the call arriving | |
| 660 | + * on the Browser says it: `OwnerService.getOwnerById` — see client-method.ts. Undefined draws the | |
| 661 | + * request straight off the Browser lifeline, as it always was. | |
| 662 | + */ | |
| 663 | +export type ClientMethods = (span: NormSpan) => string | undefined; | |
| 664 | + | |
| 665 | +const NO_CLIENT_METHODS: ClientMethods = () => undefined; | |
| 666 | + | |
| 667 | +/** The method a request landed on: `OwnerRestController.getOwner`, and its `src://` handle. */ | |
| 668 | +export interface Controller { | |
| 669 | + name: string; | |
| 670 | + handle: string; | |
| 671 | +} | |
| 672 | + | |
| 673 | +/** | |
| 674 | + * The controller method a SERVER span's request landed on — see controller-location.ts. | |
| 675 | + * Undefined draws the request with no controller: no third line, no self-call. | |
| 676 | + */ | |
| 677 | +export type Controllers = (span: NormSpan) => Controller | undefined; | |
| 678 | + | |
| 679 | +const NO_CONTROLLERS: Controllers = () => undefined; | |
| 680 | + | |
| 523 | 681 | export interface DiagramScenario { |
| 524 | 682 | title: string; |
| 525 | 683 | traces: NormSpan[][]; |
| export function renderDiagram( | ||
| 541 | 699 | options: DiagramOptions = DEFAULT_DIAGRAM_OPTIONS, |
| 542 | 700 | operations: OperationNames = defaultOperations(), |
| 543 | 701 | methodLinks: MethodLinks = NO_METHOD_LINKS, |
| 702 | + clientMethods: ClientMethods = NO_CLIENT_METHODS, | |
| 703 | + controllers: Controllers = NO_CONTROLLERS, | |
| 544 | 704 | ): RenderedDiagram { |
| 545 | 705 | // A trace can carry spans yet draw nothing — a lone browser `click`, say. Render |
| 546 | 706 | // each in isolation and keep only what has content, so an empty trace cannot |
| export function renderDiagram( | ||
| 556 | 716 | for (const spans of scenario.traces) { |
| 557 | 717 | const traceLines: string[] = []; |
| 558 | 718 | const drawn = new Set<string>(); |
| 559 | - emitTrace(spans, traceLines, drawn, options, collector, operations, scoped); | |
| 719 | + emitTrace(spans, traceLines, drawn, options, collector, operations, scoped, clientMethods, | |
| 720 | + controllers); | |
| 560 | 721 | if (traceLines.length === 0) continue; |
| 561 | 722 | drawn.forEach((p) => present.add(p)); |
| 562 | 723 | lines.push(...traceLines); |
| export function renderDiagram( | ||
| 585 | 746 | // path any more, but `renderDiagram` is a public function and a caller handing it two |
| 586 | 747 | // scenarios means two chapters, which need naming and separating. |
| 587 | 748 | const solo = sections.length === 1 ? sections[0] : undefined; |
| 749 | + const kinds = stereotypesOf(scenarios); | |
| 588 | 750 | const header = [ |
| 589 | 751 | '@startuml', |
| 590 | 752 | // ' starts a PlantUML comment: this one warns whoever opens the *file*. |
| export function renderDiagram( | ||
| 594 | 756 | `' ⚠️ GENERATED FILE — DO NOT EDIT. Every edit is lost on the next run.`, |
| 595 | 757 | 'hide footbox', |
| 596 | 758 | ...interactiveHeader, |
| 597 | - `title ${solo ? linkedSectionTitle(qualifiedTitle(solo.title, title), solo.link) : title}`, | |
| 759 | + ...GROUP_TAIL_STYLE, | |
| 760 | + `title ${solo ? linkedSectionTitle(TEST_TITLE_PREFIX + qualifiedTitle(solo.title, title), solo.link) : title}`, | |
| 598 | 761 | // footer (bottom of every page) states the diagram's provenance, naming the opt-in |
| 599 | 762 | // the reader will actually find in the file above: a .feature/.spec.ts carries the |
| 600 | 763 | // `@generate_sequence` tag, a @SpringBootTest the `@GenerateSequence` annotation. |
| export function renderDiagram( | ||
| 610 | 773 | `footer ${provenanceOf(title, scenarios)} in ${title} — ` |
| 611 | 774 | + 'generated from real traces of end-to-end ' |
| 612 | 775 | + 'test runs, do not edit ❗', |
| 613 | - ...orderedParticipants(present).map((p) => `participant ${pumlName(p)}`), | |
| 776 | + ...orderedParticipants(present).map((p) => participantLine(p, kinds.get(p))), | |
| 614 | 777 | ]; |
| 615 | 778 | // A divider per chapter, only where there are chapters to tell apart. PlantUML renders |
| 616 | 779 | // a creole link inside one, and the review page resolves the handle against its own |
| export function renderPuml( | ||
| 629 | 792 | options: DiagramOptions = DEFAULT_DIAGRAM_OPTIONS, |
| 630 | 793 | operations: OperationNames = defaultOperations(), |
| 631 | 794 | methodLinks: MethodLinks = NO_METHOD_LINKS, |
| 795 | + clientMethods: ClientMethods = NO_CLIENT_METHODS, | |
| 796 | + controllers: Controllers = NO_CONTROLLERS, | |
| 632 | 797 | ): string { |
| 633 | - return renderDiagram(title, scenarios, options, operations, methodLinks).puml; | |
| 798 | + return renderDiagram(title, scenarios, options, operations, methodLinks, clientMethods, controllers).puml; | |
| 634 | 799 | } |
| 635 | 800 | |
| 636 | 801 | interface DiagramSection { |
| 1 | +@fixture:default | |
| 1 | 2 | Feature: Search owners by last name |
| 2 | 3 | As a clinic user |
| 3 | 4 | I want to filter owners by typing part of a last name |
| async function expectOwnersListed(world: PlaywrightWorld, expected: string[]): P | ||
| 27 | 27 | /** |
| 28 | 28 | * Remembers every owner the clinic holds, after checking that the ones the |
| 29 | 29 | * Background names are among them — so a changed seed (Flyway's |
| 30 | - * db/seed/R__seed.sql) fails on the Given instead of looking like a broken search. | |
| 30 | + * db/seed/R__seed.sql, which the feature asks for with @fixture:default) fails on | |
| 31 | + * the Given instead of looking like a broken search. | |
| 31 | 32 | */ |
| 32 | 33 | Given('the clinic has these owners', async function (this: PlaywrightWorld, owners: DataTable) { |
| 33 | 34 | const {data} = await axios.get(`${API_BASE}/owners`, {timeout: 10_000}); |
| 34 | 35 | if (!Array.isArray(data) || data.length === 0) { |
| 35 | - throw new Error('The API returned no owners — is the backend up and the DB seeded by Flyway?'); | |
| 36 | + throw new Error('The API returned no owners — is the backend up, and was the DB reset to ' + | |
| 37 | + 'the @fixture:default this feature declares?'); | |
| 36 | 38 | } |
| 37 | 39 | const names: string[] = data.map(fullName); |
| 38 | 40 | expect(names).toEqual(expect.arrayContaining(owners.raw().map(([name]) => name.trim()))); |
| 1 | +import {Page} from '@playwright/test'; | |
| 2 | +import * as fs from 'fs'; | |
| 3 | +import * as path from 'path'; | |
| 4 | +import {SourceMapConsumer} from 'source-map'; | |
| 5 | +import {CallSite, idsOfTraceparent, parseStack, replayableHeaders, requestFrames} from '../genseq/call-site'; | |
| 6 | +import {TestWindow} from '../genseq/generate'; | |
| 7 | +import {windowFileName} from './trace-window-store'; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * One request as it left the page: the W3C header it carried, then the JS stacks that explain | |
| 11 | + * it. Its own stack first, then the stack that scheduled each task it ran in, nearest first. | |
| 12 | + */ | |
| 13 | +export interface RawCallSite { | |
| 14 | + traceparent: string; | |
| 15 | + stacks: string[]; | |
| 16 | + /** Every header the page set on the request, the trace context included. */ | |
| 17 | + headers?: Record<string, string>; | |
| 18 | +} | |
| 19 | + | |
| 20 | +const BINDING = '__genseqCallSite'; | |
| 21 | + | |
| 22 | +/** Enough for a router hop and a timer or two; a timer rearming itself must not chain for ever. */ | |
| 23 | +const MAX_CAUSES = 16; | |
| 24 | + | |
| 25 | +/** | |
| 26 | + * Runs in the page before any of its scripts. Serialized by Playwright, so it may only touch | |
| 27 | + * what it is handed and the browser's own globals. | |
| 28 | + * | |
| 29 | + * Installed first means wrapped last: zone.js and the OTel instrumentation both patch these | |
| 30 | + * same methods on top of this one, so by the time a request reaches `send` here OTel has | |
| 31 | + * already called `setRequestHeader('traceparent', …)` — and the stack still has everybody | |
| 32 | + * above it, down to the line of application code that started the call. | |
| 33 | + * | |
| 34 | + * Unless that code has already returned. `router.navigate` hops over a `Promise.resolve` in | |
| 35 | + * the router, so the request of a sort or a page change leaves from a stack of 226 frames with | |
| 36 | + * no application frame on it. V8's async stacks cannot help: zone.js drains its own microtask | |
| 37 | + * queue at the end of the click, so to V8 it is one synchronous task (measured with | |
| 38 | + * Debugger.setAsyncCallStackDepth: no async parent at all). The hop is only visible to Zone, | |
| 39 | + * which is why the stack is also taken wherever Zone schedules a task. An Error is cheap until | |
| 40 | + * its `.stack` is read, which happens only for a task a request ran in. | |
| 41 | + * | |
| 42 | + * Every header the page sets is taken on the way too: nothing else records them, and the | |
| 43 | + * request's detail panel shows them and replays the call with them (Victor, 10 Nov 2026). | |
| 44 | + * Taken here whole and filtered in Node (`replayableHeaders`), which a unit test can reach. | |
| 45 | + */ | |
| 46 | +function wrapRequestSenders({binding, maxCauses}: {binding: string; maxCauses: number}): void { | |
| 47 | + const page = globalThis as any; | |
| 48 | + // The default of 10 frames ends inside rxjs: a subscribe chain alone is deeper than that. | |
| 49 | + page.Error.stackTraceLimit = Infinity; | |
| 50 | + | |
| 51 | + interface Cause {error: Error; up: Cause | null; depth: number} | |
| 52 | + const causes = new WeakMap<object, Cause>(); | |
| 53 | + let running: Cause | null = null; | |
| 54 | + | |
| 55 | + // A click is not caused by the code that registered its listener, so an event task has no | |
| 56 | + // cause and starts a chain of its own. Zone is trapped on its way in: it has to be hooked | |
| 57 | + // after polyfills.js defines it and before the app starts scheduling. | |
| 58 | + const followTasks = (Zone: any): void => { | |
| 59 | + const {scheduleTask, runTask} = Zone.prototype; | |
| 60 | + Zone.prototype.scheduleTask = function (this: object, task: any) { | |
| 61 | + if (task.type !== 'eventTask') { | |
| 62 | + const depth = (running?.depth ?? 0) + 1; | |
| 63 | + causes.set(task, {error: new page.Error(), up: depth < maxCauses ? running : null, depth}); | |
| 64 | + } | |
| 65 | + return scheduleTask.call(this, task); | |
| 66 | + }; | |
| 67 | + Zone.prototype.runTask = function (this: object, task: object, ...args: unknown[]) { | |
| 68 | + const outer = running; | |
| 69 | + running = causes.get(task) ?? null; | |
| 70 | + try { | |
| 71 | + return runTask.call(this, task, ...args); | |
| 72 | + } finally { | |
| 73 | + running = outer; | |
| 74 | + } | |
| 75 | + }; | |
| 76 | + }; | |
| 77 | + let zone: unknown; | |
| 78 | + Object.defineProperty(page, 'Zone', { | |
| 79 | + configurable: true, | |
| 80 | + get: () => zone, | |
| 81 | + set: (value) => { | |
| 82 | + zone = value; | |
| 83 | + followTasks(value); | |
| 84 | + }, | |
| 85 | + }); | |
| 86 | + | |
| 87 | + // From the root zone, or the promise the binding returns would count as Angular work and | |
| 88 | + // the app would run an extra change detection for every request it made under test. | |
| 89 | + const report = (traceparent: string, headers: Record<string, string>): void => { | |
| 90 | + const stacks = [new page.Error().stack ?? '']; | |
| 91 | + for (let cause = running; cause; cause = cause.up) stacks.push(cause.error.stack ?? ''); | |
| 92 | + const send = () => page[binding](traceparent, stacks, headers); | |
| 93 | + page.Zone?.root ? page.Zone.root.run(send) : send(); | |
| 94 | + }; | |
| 95 | + const traceparentOf = (headers: Record<string, string>): string | undefined => | |
| 96 | + Object.entries(headers).find(([name]) => name.toLowerCase() === 'traceparent')?.[1]; | |
| 97 | + | |
| 98 | + const proto = page.XMLHttpRequest.prototype; | |
| 99 | + const sent = new WeakMap<object, Record<string, string>>(); | |
| 100 | + // `open` starts a new request on the same object, and the spec drops its headers there too. | |
| 101 | + const open = proto.open; | |
| 102 | + proto.open = function (this: object, ...args: unknown[]) { | |
| 103 | + sent.delete(this); | |
| 104 | + return open.apply(this, args); | |
| 105 | + }; | |
| 106 | + const setRequestHeader = proto.setRequestHeader; | |
| 107 | + proto.setRequestHeader = function (this: object, name: string, value: string) { | |
| 108 | + const headers = sent.get(this) ?? {}; | |
| 109 | + // A header set twice goes out once, its values joined — the way the browser sends it. | |
| 110 | + headers[name] = Object.hasOwn(headers, name) ? `${headers[name]}, ${value}` : String(value); | |
| 111 | + sent.set(this, headers); | |
| 112 | + return setRequestHeader.call(this, name, value); | |
| 113 | + }; | |
| 114 | + const send = proto.send; | |
| 115 | + proto.send = function (this: object, ...args: unknown[]) { | |
| 116 | + const headers = sent.get(this) ?? {}; | |
| 117 | + const traceparent = traceparentOf(headers); | |
| 118 | + if (traceparent) report(traceparent, headers); | |
| 119 | + return send.apply(this, args); | |
| 120 | + }; | |
| 121 | + | |
| 122 | + const fetch = page.fetch; | |
| 123 | + page.fetch = function (this: unknown, input: any, init?: any) { | |
| 124 | + const headers: Record<string, string> = {}; | |
| 125 | + new page.Headers(init?.headers ?? input?.headers ?? {}) | |
| 126 | + .forEach((value: string, name: string) => {headers[name] = value;}); | |
| 127 | + const traceparent = traceparentOf(headers); | |
| 128 | + if (traceparent) report(traceparent, headers); | |
| 129 | + return fetch.call(this, input, init); | |
| 130 | + }; | |
| 131 | +} | |
| 132 | + | |
| 133 | +/** | |
| 134 | + * From now on, every traced request this page sends is remembered with the stacks that sent | |
| 135 | + * it. A binding rather than an array on `window`: the array would die with the first full | |
| 136 | + * navigation, and a test may make several. | |
| 137 | + */ | |
| 138 | +export async function recordCallSites(page: Page): Promise<RawCallSite[]> { | |
| 139 | + const sites: RawCallSite[] = []; | |
| 140 | + await page.exposeFunction(BINDING, (traceparent: string, stacks: string[], headers?: Record<string, string>) => { | |
| 141 | + sites.push({traceparent, stacks, ...(headers ? {headers} : {})}); | |
| 142 | + }); | |
| 143 | + await page.addInitScript(wrapRequestSenders, {binding: BINDING, maxCauses: MAX_CAUSES}); | |
| 144 | + return sites; | |
| 145 | +} | |
| 146 | + | |
| 147 | +// Parsed once per process: one bundle's map is 7 MB and every test's stacks point into it. | |
| 148 | +const maps = new Map<string, Promise<SourceMapConsumer | undefined>>(); | |
| 149 | + | |
| 150 | +/** | |
| 151 | + * The map beside a script, over HTTP from the server that served it. The Docker build keeps | |
| 152 | + * its maps `hidden`, which only drops the `sourceMappingURL` comment: nginx serves | |
| 153 | + * `main.<hash>.js.map` next to the bundle, and `ng serve` does the same for `main.js`. | |
| 154 | + */ | |
| 155 | +function mapOf(script: string): Promise<SourceMapConsumer | undefined> { | |
| 156 | + let map = maps.get(script); | |
| 157 | + if (!map) { | |
| 158 | + map = fetch(`${script}.map`) | |
| 159 | + .then((response) => (response.ok ? response.json() : undefined)) | |
| 160 | + .then((raw) => (raw ? new SourceMapConsumer(raw) : undefined)) | |
| 161 | + .catch(() => undefined); | |
| 162 | + maps.set(script, map); | |
| 163 | + } | |
| 164 | + return map; | |
| 165 | +} | |
| 166 | + | |
| 167 | +async function toCallSites(sites: RawCallSite[]): Promise<CallSite[]> { | |
| 168 | + const stacks = sites.flatMap((s) => s.stacks); | |
| 169 | + const scripts = [...new Set(stacks.flatMap((stack) => parseStack(stack).map((f) => f.url)))]; | |
| 170 | + const loaded = new Map<string, SourceMapConsumer>(); | |
| 171 | + for (const script of scripts) { | |
| 172 | + const map = await mapOf(script); | |
| 173 | + if (map) loaded.set(script, map); | |
| 174 | + } | |
| 175 | + return sites.flatMap((site) => { | |
| 176 | + const ids = idsOfTraceparent(site.traceparent); | |
| 177 | + const headers = replayableHeaders(site.headers); | |
| 178 | + return ids ? [{...ids, frames: requestFrames(site.stacks, loaded), ...(headers ? {headers} : {})}] : []; | |
| 179 | + }); | |
| 180 | +} | |
| 181 | + | |
| 182 | +/** A test's requests on disk. `source` is what `forgetWindowsOf` sorts files to their runner by. */ | |
| 183 | +type CallSiteFile = Pick<TestWindow, 'title' | 'source'> & {sites: CallSite[]}; | |
| 184 | + | |
| 185 | +function readFileOf(file: string): CallSite[] { | |
| 186 | + try { | |
| 187 | + return (JSON.parse(fs.readFileSync(file, 'utf-8')) as CallSiteFile).sites ?? []; | |
| 188 | + } catch { | |
| 189 | + return []; // not there yet, or a run killed mid-write: that test's links, not every diagram's | |
| 190 | + } | |
| 191 | +} | |
| 192 | + | |
| 193 | +/** | |
| 194 | + * One file per test, named as its trace window is, and added to: every row of a Scenario | |
| 195 | + * Outline runs under the same title. A runner clears its own at the start, with | |
| 196 | + * `forgetWindowsOf`, and only its own: the last diagram step of a review re-fetches both | |
| 197 | + * suites, and needs both suites' files. | |
| 198 | + * | |
| 199 | + * Translated here, while the stack that served the bundle is certainly still up. Best-effort, | |
| 200 | + * like the diagram it feeds: a request with no call site is an arrow with no link, never a | |
| 201 | + * red test. | |
| 202 | + */ | |
| 203 | +export async function saveCallSites( | |
| 204 | + dir: string, test: Pick<TestWindow, 'title' | 'source'>, sites: RawCallSite[], | |
| 205 | +): Promise<void> { | |
| 206 | + try { | |
| 207 | + const translated = await toCallSites(sites); | |
| 208 | + fs.mkdirSync(dir, {recursive: true}); | |
| 209 | + const file = path.join(dir, windowFileName(test)); | |
| 210 | + const entry: CallSiteFile = {...test, sites: [...readFileOf(file), ...translated]}; | |
| 211 | + fs.writeFileSync(file, JSON.stringify(entry, null, 2)); | |
| 212 | + } catch (err) { | |
| 213 | + console.warn(`⚠️ Could not save the call sites of "${test.title}": ${(err as Error).message}`); | |
| 214 | + } | |
| 215 | +} | |
| 216 | + | |
| 217 | +/** Every request on file. */ | |
| 218 | +export function readCallSites(dir: string): CallSite[] { | |
| 219 | + if (!fs.existsSync(dir)) return []; | |
| 220 | + return fs.readdirSync(dir).filter((name) => name.endsWith('.json')) | |
| 221 | + .flatMap((name) => readFileOf(path.join(dir, name))); | |
| 222 | +} |
| 1 | +import axios from 'axios'; | |
| 2 | + | |
| 3 | +// What the database holds when a test starts: the one thing a test must SAY rather than | |
| 4 | +// inherit. A Playwright spec says it with `test.use({dbFixture: 'default'})` (or the name of a | |
| 5 | +// db/fixtures/<name>.sql); a Cucumber feature or scenario with the tag `@fixture:default`. | |
| 6 | +// Saying nothing means 'empty' — every table emptied, the reference rows (pet types, | |
| 7 | +// specialties) written back — which is also what an undeclared Spring test starts on | |
| 8 | +// (petclinic-backend/src/test/resources/config/application.properties). | |
| 9 | +// | |
| 10 | +// The reset itself is the review environment's sidecar (docker/reset), reached through the | |
| 11 | +// frontend's nginx at /__reset — never the backend, which must not have such a route | |
| 12 | +// (no-reset-endpoint.spec.ts). So it exists only on a docker stack: `ng serve` on :4200 has no | |
| 13 | +// /__reset, and there a test runs on whatever the database holds, as every test did before. | |
| 14 | + | |
| 15 | +export type DbFixture = 'empty' | 'default' | string; | |
| 16 | + | |
| 17 | +/** Words of a tag or `dbFixture`, to the sidecar's route: Default is its root, the rest a name. */ | |
| 18 | +function route(fixture: DbFixture): string { | |
| 19 | + return fixture === 'default' || fixture === 'seed' ? '/__reset' : `/__reset/${fixture}`; | |
| 20 | +} | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * The fixture a Cucumber pickle declares with `@fixture:<name>`, else 'empty'. A pickle lists | |
| 24 | + * its Feature's tags before its own, so the last one wins: the scenario's tag beats the Feature's. | |
| 25 | + */ | |
| 26 | +export function fixtureOfTags(tags: readonly {name: string}[]): DbFixture { | |
| 27 | + const named = tags.map(t => /^@fixture[:=-]([a-z0-9][a-z0-9-]*)$/.exec(t.name)?.[1]).filter(Boolean); | |
| 28 | + return (named[named.length - 1] as string | undefined) ?? 'empty'; | |
| 29 | +} | |
| 30 | + | |
| 31 | +let warned = false; | |
| 32 | + | |
| 33 | +/** | |
| 34 | + * Puts the database on `fixture`. Returns false, once warned, when there is no sidecar to ask — | |
| 35 | + * the stack is not a docker one — and throws when there is one and the reset fails: a test that | |
| 36 | + * declared its data must not run on other data and pass for the wrong reason. | |
| 37 | + */ | |
| 38 | +export async function resetDatabaseTo(fixture: DbFixture): Promise<boolean> { | |
| 39 | + const base = (process.env.BASE_URL || 'http://127.0.0.1:4200').replace(/\/$/, ''); | |
| 40 | + const res = await axios.post(base + route(fixture), {}, { | |
| 41 | + timeout: 30_000, validateStatus: () => true, responseType: 'text', transformResponse: r => r, | |
| 42 | + }); | |
| 43 | + let body: any; | |
| 44 | + try { body = JSON.parse(res.data); } catch { body = undefined; } | |
| 45 | + if ((res.status === 404 && !body) || (res.status < 400 && body?.ok === undefined)) { | |
| 46 | + if (!warned) { | |
| 47 | + warned = true; | |
| 48 | + console.warn(`⚠️ no reset sidecar behind ${base}/__reset (not a docker stack): tests run on ` + | |
| 49 | + `whatever the database holds, not on the DB fixture they declare.`); | |
| 50 | + } | |
| 51 | + return false; | |
| 52 | + } | |
| 53 | + if (!body?.ok) { | |
| 54 | + throw new Error(`reset to DB fixture '${fixture}' failed (${res.status}): ${body?.error ?? res.data}`); | |
| 55 | + } | |
| 56 | + return true; | |
| 57 | +} |
| export default async function globalSetup(): Promise<void> { | ||
| 12 | 12 | path.join(__dirname, '..', '..', 'test-results', 'trace-windows'), |
| 13 | 13 | PLAYWRIGHT_SOURCES, |
| 14 | 14 | ); |
| 15 | + forgetWindowsOf( | |
| 16 | + path.join(__dirname, '..', '..', 'test-results', 'call-sites'), | |
| 17 | + PLAYWRIGHT_SOURCES, | |
| 18 | + ); | |
| 15 | 19 | // Per-test coverage for /human-review (support/coverage.ts): a clean folder per run. |
| 16 | 20 | startCoverageRun('playwright'); |
| 17 | 21 | } |
| 1 | 1 | import {execFileSync} from 'child_process'; |
| 2 | 2 | import * as path from 'path'; |
| 3 | 3 | import {runGenerate, PLAYWRIGHT_SOURCES} from '../genseq/generate'; |
| 4 | +import {resetDatabaseTo} from './db-fixture'; | |
| 4 | 5 | |
| 5 | 6 | // Pull the acceptance suite's coverage out of the backend JVM while it is STILL RUNNING. |
| 6 | 7 | // That JVM is the one the browser exercised, and surefire's JaCoCo agent never sees it — |
| function dumpAcceptanceCoverage(): void { | ||
| 54 | 55 | export default async function globalTeardown(): Promise<void> { |
| 55 | 56 | dumpAcceptanceCoverage(); |
| 56 | 57 | await runGenerate(PLAYWRIGHT_SOURCES); |
| 58 | + // Each test reset the database to the fixture it declares; leave the stack on Default, the | |
| 59 | + // dataset the app's demo comes up in, rather than on whatever the last test asked for. | |
| 60 | + await resetDatabaseTo('default').catch(e => console.warn(`could not put the stack back on Default: ${e}`)); | |
| 57 | 61 | } |
| import {appendWindow} from './trace-window-store'; | ||
| 4 | 4 | import {flushBrowserSpans} from './otel-flush'; |
| 5 | 5 | import {isTagged, shouldGenerateSequence} from '../genseq/sequence-tag'; |
| 6 | 6 | import {startTestCoverage, stopTestCoverage} from './coverage'; |
| 7 | +import {DbFixture, resetDatabaseTo} from './db-fixture'; | |
| 8 | +import {recordCallSites, saveCallSites} from './call-sites'; | |
| 7 | 9 | |
| 8 | 10 | // The Playwright counterpart of src/glue/world.ts: it honours the very same |
| 9 | 11 | // @generate_sequence opt-in, only read from Playwright's test tags instead of |
| import {startTestCoverage, stopTestCoverage} from './coverage'; | ||
| 12 | 14 | // window, so no .puml is produced for it. |
| 13 | 15 | |
| 14 | 16 | const WINDOWS_DIR = path.join(__dirname, '..', '..', 'test-results', 'trace-windows'); |
| 17 | +const CALL_SITES_DIR = path.join(__dirname, '..', '..', 'test-results', 'call-sites'); | |
| 15 | 18 | |
| 16 | 19 | // Pads the recorded window so the BatchSpanProcessor's async export (and Tempo |
| 17 | 20 | // ingestion lag) still falls inside the search range. |
| 18 | 21 | const PRE_PAD_MS = 1_000; |
| 19 | 22 | const POST_PAD_MS = 5_000; |
| 20 | 23 | |
| 21 | -export const test = base.extend({ | |
| 22 | - page: async ({page}, use, testInfo) => { | |
| 24 | +export const test = base.extend<{dbFixture: DbFixture; database: void}>({ | |
| 25 | + // The data the test starts on (support/db-fixture.ts): 'empty' unless the spec says | |
| 26 | + // `test.use({dbFixture: 'default'})` — or the name of a db/fixtures/<name>.sql. | |
| 27 | + dbFixture: ['empty', {option: true}], | |
| 28 | + // Reset before EVERY test, auto so a test with no page (no-reset-endpoint.spec.ts) gets it too. | |
| 29 | + // That is only correct with one test at a time: the reset empties the one database every | |
| 30 | + // worker shares, so playwright.config.ts runs the suite on a single worker. | |
| 31 | + database: [async ({dbFixture}, use) => { | |
| 32 | + await resetDatabaseTo(dbFixture); | |
| 33 | + await use(); | |
| 34 | + }, {auto: true}], | |
| 35 | + // `database` first: the trace window and the coverage dump open after the reset, never on it. | |
| 36 | + page: async ({page, database}, use, testInfo) => { | |
| 37 | + void database; | |
| 23 | 38 | // Per-test coverage for /human-review (support/coverage.ts). Inert without |
| 24 | 39 | // COVERAGE_DIR; around the body so a traced test and an untraced one are measured alike. |
| 25 | 40 | await startTestCoverage(page); |
| export const test = base.extend({ | ||
| 44 | 59 | (globalThis as any).__E2E_TEST_NAME__ = name; |
| 45 | 60 | }, testInfo.title); |
| 46 | 61 | |
| 62 | + // Which line of the frontend sent each request (support/call-sites.ts). | |
| 63 | + const callSites = await recordCallSites(page); | |
| 64 | + const source = path.relative(path.join(__dirname, '..', '..'), testInfo.file); | |
| 65 | + | |
| 47 | 66 | const startMs = Date.now() - PRE_PAD_MS; |
| 48 | 67 | await use(page); |
| 49 | 68 | await harvest(); |
| 50 | 69 | await flushBrowserSpans(page); |
| 70 | + await saveCallSites(CALL_SITES_DIR, {title: testInfo.title, source}, callSites); | |
| 51 | 71 | appendWindow(WINDOWS_DIR, { |
| 52 | 72 | title: testInfo.title, |
| 53 | - source: path.relative(path.join(__dirname, '..', '..'), testInfo.file), | |
| 73 | + source, | |
| 54 | 74 | startMs, |
| 55 | 75 | endMs: Date.now() + POST_PAD_MS, |
| 56 | 76 | // Named by GENSEQ_SELECT, untagged: the diagram's footer must not claim a tag. |
| export const test = base.extend({ | ||
| 60 | 80 | }); |
| 61 | 81 | |
| 62 | 82 | export {expect} from '@playwright/test'; |
| 83 | +export type {Page} from '@playwright/test'; |
| import {isTagged, shouldGenerateSequence} from '../genseq/sequence-tag'; | ||
| 11 | 11 | import {runGenerate, CUCUMBER_SOURCES} from '../genseq/generate'; |
| 12 | 12 | import {startCoverageRun, startTestCoverage, stopTestCoverage} from './coverage'; |
| 13 | 13 | import {keepCaptionAcrossLoads, showCaption} from './captions'; |
| 14 | +import {fixtureOfTags, resetDatabaseTo} from './db-fixture'; | |
| 15 | +import {RawCallSite, recordCallSites, saveCallSites} from './call-sites'; | |
| 14 | 16 | |
| 15 | 17 | setDefaultTimeout(60_000); |
| 16 | 18 | |
| 17 | 19 | const WINDOWS_DIR = path.join(__dirname, '..', '..', 'test-results', 'trace-windows'); |
| 20 | +const CALL_SITES_DIR = path.join(__dirname, '..', '..', 'test-results', 'call-sites'); | |
| 18 | 21 | |
| 19 | 22 | // Playwright's own recording of each scenario — screenshots, DOM snapshots, console, |
| 20 | 23 | // network — the same zip `trace: 'on'` leaves behind for a *.spec.ts. Cucumber runs |
| export class PlaywrightWorld extends World { | ||
| 56 | 59 | // Named by GENSEQ_SELECT, untagged: the diagram's footer must not claim a tag. |
| 57 | 60 | traceSelected?: boolean; |
| 58 | 61 | traceStartMs?: number; |
| 62 | + // Which line of the frontend sent each of those requests (support/call-sites.ts). | |
| 63 | + callSites?: RawCallSite[]; | |
| 59 | 64 | // Set for every scenario when PW_TRACE=on: where its recording lands, and when it began. |
| 60 | 65 | recordingZip?: string; |
| 61 | 66 | recordingStartMs?: number; |
| setWorldConstructor(PlaywrightWorld); | ||
| 85 | 90 | // of its own source files, and the other suite's are none of its business. |
| 86 | 91 | BeforeAll(function () { |
| 87 | 92 | forgetWindowsOf(WINDOWS_DIR, CUCUMBER_SOURCES); |
| 93 | + forgetWindowsOf(CALL_SITES_DIR, CUCUMBER_SOURCES); | |
| 88 | 94 | // Per-test coverage for /human-review (support/coverage.ts): a clean folder per run. |
| 89 | 95 | startCoverageRun('cucumber'); |
| 90 | 96 | // A clean slate per run, like the report the Playwright suite rewrites: a recording |
| BeforeAll(function () { | ||
| 96 | 102 | }); |
| 97 | 103 | |
| 98 | 104 | Before(async function (this: PlaywrightWorld, {pickle}: ITestCaseHookParameter) { |
| 105 | + // The data the scenario starts on (db-fixture.ts): `@fixture:<name>` on it or on its | |
| 106 | + // Feature, else 'empty'. Every scenario, before anything is recorded — the suite runs | |
| 107 | + // one scenario at a time, so resetting the shared database never pulls rows from under another. | |
| 108 | + await resetDatabaseTo(fixtureOfTags(pickle.tags)); | |
| 99 | 109 | this.browser = await chromium.launch({ |
| 100 | 110 | headless: !process.env.HEADED, |
| 101 | 111 | slowMo: VIDEO_ON ? Number(process.env.PW_SLOWMO ?? 400) : undefined, |
| Before(async function (this: PlaywrightWorld, {pickle}: ITestCaseHookParameter) | ||
| 125 | 135 | await this.page.addInitScript((name) => { |
| 126 | 136 | (globalThis as any).__E2E_TEST_NAME__ = name; |
| 127 | 137 | }, pickle.name); |
| 138 | + this.callSites = await recordCallSites(this.page); | |
| 128 | 139 | this.traceStartMs = Date.now() - PRE_PAD_MS; |
| 129 | 140 | } |
| 130 | 141 | }); |
| After(async function (this: PlaywrightWorld, {pickle, gherkinDocument, result}: | ||
| 166 | 177 | } |
| 167 | 178 | if (this.traceTitle && this.traceStartMs !== undefined) { |
| 168 | 179 | await flushBrowserSpans(this.page); |
| 180 | + await saveCallSites(CALL_SITES_DIR, | |
| 181 | + {title: this.traceTitle, source: this.traceSource!}, this.callSites ?? []); | |
| 169 | 182 | appendWindow(WINDOWS_DIR, { |
| 170 | 183 | title: this.traceTitle, |
| 171 | 184 | source: this.traceSource!, |
| function recordingSlug(uri: string, name: string): string { | ||
| 214 | 227 | // runGenerate() never throws, so a telemetry hiccup can't fail the suite. |
| 215 | 228 | AfterAll(async function () { |
| 216 | 229 | await runGenerate(CUCUMBER_SOURCES); |
| 230 | + // Back on Default, the dataset the app's demo comes up in, whatever the last scenario declared. | |
| 231 | + await resetDatabaseTo('default').catch(e => console.warn(`could not put the stack back on Default: ${e}`)); | |
| 217 | 232 | }); |
| 1 | +# Books onto an owner's pet and picks among vets that are already there: the Default dataset's. | |
| 2 | +@fixture:default | |
| 3 | +Feature: Choose the vet who attends a visit | |
| 4 | + As a clinic user | |
| 5 | + I want to say which vet will attend a visit when I book it | |
| 6 | + So that the owner's page and the visits screen show who will see the pet | |
| 7 | + | |
| 8 | + Background: | |
| 9 | + Given an owner with at least one pet | |
| 10 | + | |
| 11 | + Scenario: Book a visit attended by a vet, and see the vet on the owner page and on all visits | |
| 12 | + When I open that owner's page | |
| 13 | + And I start a new visit for their first pet | |
| 14 | + And I date the visit "2026-05-12" and describe it uniquely | |
| 15 | + And I choose the first vet the form offers | |
| 16 | + And I submit the visit | |
| 17 | + Then I am back on that owner's page | |
| 18 | + And the visit on the owner's page reads the chosen vet | |
| 19 | + When I open the all visits screen | |
| 20 | + Then the visit on the all visits screen reads the chosen vet | |
| 21 | + | |
| 22 | + Scenario: Book a visit with nobody yet as its vet, and see it read "No vet" everywhere | |
| 23 | + When I open that owner's page | |
| 24 | + And I start a new visit for their first pet | |
| 25 | + Then the form's vet field reads "No vet" | |
| 26 | + When I date the visit "2026-05-12" and describe it uniquely | |
| 27 | + And I submit the visit | |
| 28 | + Then I am back on that owner's page | |
| 29 | + And the visit on the owner's page reads "No vet" | |
| 30 | + When I open the all visits screen | |
| 31 | + Then the visit on the all visits screen reads "No vet" |
| 1 | +import {Given, When, Then} from '@cucumber/cucumber'; | |
| 2 | +import {PlaywrightWorld} from './support/world'; | |
| 3 | +import { | |
| 4 | + an_owner_with_at_least_one_pet_exists, | |
| 5 | + click_add_visit_for_first_pet, | |
| 6 | + expect_all_visits_screen_shows_vet, | |
| 7 | + expect_back_on_owner_detail_page, | |
| 8 | + expect_pet_visit_list_shows_vet, | |
| 9 | + expect_visit_form_offers_nobody_yet, | |
| 10 | + fill_visit_date_and_unique_description, | |
| 11 | + open_all_visits_screen, | |
| 12 | + open_owner_detail_page, | |
| 13 | + select_first_vet_in_visit_form, | |
| 14 | + submit_visit_form, | |
| 15 | +} from './add-visit.dsl'; | |
| 16 | + | |
| 17 | +// Gherkin over the sentences add-visit.dsl.ts already has: the browser work and every | |
| 18 | +// selector stay there, shared with add-visit.spec.ts, and each step below only binds a | |
| 19 | +// line of visit-vet.feature to one of them. The functions are imported raw, not through | |
| 20 | +// narrate(): a scenario's steps are its Gherkin lines, so nothing here stamps a sentence. | |
| 21 | +// | |
| 22 | +// What a scenario learns on the way — the owner, the visit's date and description, the vet | |
| 23 | +// it chose — is kept on the World, so a later Then can find that exact row again. | |
| 24 | + | |
| 25 | +type VisitWorld = PlaywrightWorld & {visitDate?: string; chosenVet?: string}; | |
| 26 | + | |
| 27 | +function known<T>(value: T | undefined, what: string): T { | |
| 28 | + if (value === undefined) { | |
| 29 | + throw new Error(`Expected ${what} to have been set by an earlier step of the scenario`); | |
| 30 | + } | |
| 31 | + return value; | |
| 32 | +} | |
| 33 | + | |
| 34 | +Given('an owner with at least one pet', async function (this: VisitWorld) { | |
| 35 | + const {ownerId, petId} = await an_owner_with_at_least_one_pet_exists(); | |
| 36 | + this.ownerId = ownerId; | |
| 37 | + this.petId = petId; | |
| 38 | +}); | |
| 39 | + | |
| 40 | +When('I open that owner\'s page', async function (this: VisitWorld) { | |
| 41 | + await open_owner_detail_page(this.page, known(this.ownerId, 'the owner')); | |
| 42 | +}); | |
| 43 | + | |
| 44 | +When('I start a new visit for their first pet', async function (this: VisitWorld) { | |
| 45 | + await click_add_visit_for_first_pet(this.page, 'Add Visit'); | |
| 46 | +}); | |
| 47 | + | |
| 48 | +When('I date the visit {string} and describe it uniquely', async function (this: VisitWorld, date: string) { | |
| 49 | + this.visitDate = date; | |
| 50 | + this.visitDescription = await fill_visit_date_and_unique_description(this.page, date); | |
| 51 | +}); | |
| 52 | + | |
| 53 | +When('I choose the first vet the form offers', async function (this: VisitWorld) { | |
| 54 | + this.chosenVet = await select_first_vet_in_visit_form(this.page); | |
| 55 | +}); | |
| 56 | + | |
| 57 | +When('I submit the visit', async function (this: VisitWorld) { | |
| 58 | + await submit_visit_form(this.page); | |
| 59 | +}); | |
| 60 | + | |
| 61 | +When('I open the all visits screen', async function (this: VisitWorld) { | |
| 62 | + await open_all_visits_screen(this.page); | |
| 63 | +}); | |
| 64 | + | |
| 65 | +Then('the form\'s vet field reads "No vet"', async function (this: VisitWorld) { | |
| 66 | + await expect_visit_form_offers_nobody_yet(this.page); | |
| 67 | +}); | |
| 68 | + | |
| 69 | +Then('I am back on that owner\'s page', async function (this: VisitWorld) { | |
| 70 | + await expect_back_on_owner_detail_page(this.page, known(this.ownerId, 'the owner')); | |
| 71 | +}); | |
| 72 | + | |
| 73 | +Then('the visit on the owner\'s page reads the chosen vet', async function (this: VisitWorld) { | |
| 74 | + await visitOnOwnerPageReads(this, known(this.chosenVet, 'the chosen vet')); | |
| 75 | +}); | |
| 76 | + | |
| 77 | +Then('the visit on the owner\'s page reads {string}', async function (this: VisitWorld, vet: string) { | |
| 78 | + await visitOnOwnerPageReads(this, vet); | |
| 79 | +}); | |
| 80 | + | |
| 81 | +Then('the visit on the all visits screen reads the chosen vet', async function (this: VisitWorld) { | |
| 82 | + await visitOnAllVisitsReads(this, known(this.chosenVet, 'the chosen vet')); | |
| 83 | +}); | |
| 84 | + | |
| 85 | +Then('the visit on the all visits screen reads {string}', async function (this: VisitWorld, vet: string) { | |
| 86 | + await visitOnAllVisitsReads(this, vet); | |
| 87 | +}); | |
| 88 | + | |
| 89 | +async function visitOnOwnerPageReads(world: VisitWorld, vet: string): Promise<void> { | |
| 90 | + await expect_pet_visit_list_shows_vet(world.page, known(world.visitDate, 'the visit date'), | |
| 91 | + known(world.visitDescription, 'the visit description'), vet); | |
| 92 | +} | |
| 93 | + | |
| 94 | +async function visitOnAllVisitsReads(world: VisitWorld, vet: string): Promise<void> { | |
| 95 | + await expect_all_visits_screen_shows_vet(world.page, known(world.visitDescription, 'the visit description'), vet); | |
| 96 | +} |
| import * as path from 'path'; | ||
| 4 | 4 | import {VisitsPage} from './pages/VisitsPage'; |
| 5 | 5 | import {ApiClient, VisitDto} from './support/api-client'; |
| 6 | 6 | |
| 7 | +// Reads the seed's visits: the page must have rows to list, sort and link from. | |
| 8 | +test.use({dbFixture: 'default'}); | |
| 9 | + | |
| 7 | 10 | test.describe('Visits Page', () => { |
| 8 | 11 | let apiClient: ApiClient; |
| 9 | 12 | let screenshotDir: string; |
| 1 | +{ | |
| 2 | + "name": "petclinic-local", | |
| 3 | + "owner": { | |
| 4 | + "name": "Victor Rentea" | |
| 5 | + }, | |
| 6 | + "metadata": { | |
| 7 | + "description": "Local marketplace demo: one folder, no git, shared by every petclinic service" | |
| 8 | + }, | |
| 9 | + "plugins": [ | |
| 10 | + { | |
| 11 | + "name": "petclinic-tools", | |
| 12 | + "description": "Skills shared by every petclinic service, installed instead of copied", | |
| 13 | + "source": "./plugins/petclinic-tools" | |
| 14 | + } | |
| 15 | + ] | |
| 16 | +} |
| 1 | +# Demo: a plugin marketplace from a plain folder | |
| 2 | + | |
| 3 | +No git, no hosting: this folder is a marketplace (`.claude-plugin/marketplace.json`) listing one | |
| 4 | +plugin, `petclinic-tools`, which carries one skill, `grill-me`. The same folder installs in all three | |
| 5 | +harnesses. Run from the repo root: | |
| 6 | + | |
| 7 | +| Harness | Install | Use | | |
| 8 | +| --- | --- | --- | | |
| 9 | +| Claude Code | `claude plugin marketplace add ./plugin-marketplace-demo`<br>`claude plugin install petclinic-tools@petclinic-local` | `/petclinic-tools:grill-me <plan>` | | |
| 10 | +| Copilot CLI | `copilot plugin marketplace add ./plugin-marketplace-demo`<br>`copilot plugin install petclinic-tools@petclinic-local` | `/petclinic-tools:grill-me <plan>` | | |
| 11 | +| Codex | `codex plugin marketplace add ./plugin-marketplace-demo`<br>`codex plugin add petclinic-tools@petclinic-local` | `$petclinic-tools:grill-me <plan>` | | |
| 12 | + | |
| 13 | +Then start the agent in `petclinic-backend/` and in `notification-service/`: the skill is there in | |
| 14 | +both, although no `.claude/skills/` holds it any more. | |
| 15 | + | |
| 16 | +Claude Code and Copilot read the plugin live from this folder; Codex copies it into its cache, so | |
| 17 | +after an edit run `codex plugin add` again. |
| 1 | +{ | |
| 2 | + "name": "petclinic-tools", | |
| 3 | + "description": "Skills shared by every petclinic service, installed instead of copied", | |
| 4 | + "author": { "name": "Victor Rentea" } | |
| 5 | +} |
| 1 | +--- | |
| 2 | +name: grill-me | |
| 3 | +description: Interview the user relentlessly about a plan, one question at a time, until there is a shared understanding. | |
| 4 | +disable-model-invocation: true | |
| 5 | +--- | |
| 6 | + | |
| 7 | +Interview me relentlessly about every aspect of this plan until we reach a shared understanding. | |
| 8 | +Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. | |
| 9 | +For each question, provide your recommended answer. | |
| 10 | +Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering😵💫. | |
| 11 | +If a question can be answered by exploring the environment (filesystem, tools, etc), look it up rather than asking me. | |
| 12 | +The decisions, though, are mine — ask one to me and wait for my answer. |
| 1 | +# refactoring-tools | |
| 2 | + | |
| 3 | +A **self-contained** [OpenRewrite](https://docs.openrewrite.org/) module. It does **not** touch the | |
| 4 | +backend build in any way — `petclinic-backend/pom.xml` has no OpenRewrite config. You run the recipes | |
| 5 | +against the backend from the command line, passing this module as a recipe artifact (see below). | |
| 6 | + | |
| 7 | +It ships two recipes, deliberately of the two different kinds: | |
| 8 | + | |
| 9 | +| Recipe | Kind | What it does | | |
| 10 | +|---|---|---| | |
| 11 | +| `InlineSingleUseLocalVariable` | **Imperative** (`Recipe` + visitor) | Inlines a local variable used exactly once | | |
| 12 | +| `ListGetFirst` → `ListGetFirstRecipe` | **Refaster template** (before/after) | Rewrites `list.get(0)` to `list.getFirst()` | | |
| 13 | + | |
| 14 | +## Why two kinds — and why the inline one can't be Refaster | |
| 15 | + | |
| 16 | +A **Refaster template** is the simplest kind to read: you write a `@BeforeTemplate` snippet and an | |
| 17 | +`@AfterTemplate` snippet, and the `rewrite-templating` annotation processor turns them into a recipe | |
| 18 | +at build time. It is a **typed, whitespace-agnostic, one-expression-in / one-expression-out** | |
| 19 | +replacement. `ListGetFirst.java` is the whole recipe: | |
| 20 | + | |
| 21 | +```java | |
| 22 | +@RecipeDescriptor(name = "...", description = "...") | |
| 23 | +public class ListGetFirst<T> { | |
| 24 | + @BeforeTemplate T getZero(List<T> list) { return list.get(0); } | |
| 25 | + @AfterTemplate T getFirst(List<T> list) { return list.getFirst(); } | |
| 26 | +} | |
| 27 | +``` | |
| 28 | + | |
| 29 | +The **inline-single-use** refactoring **cannot** be expressed that way. Refaster matches one | |
| 30 | +expression and rewrites it in place; it can't: | |
| 31 | +- **count** how many times a variable is used (the whole safety rule is "exactly one use"), | |
| 32 | +- **delete** the declaration statement while editing a *different* statement, | |
| 33 | +- reason about statement **order**, loops, lambdas or conditionals. | |
| 34 | + | |
| 35 | +That needs data-flow + multi-statement surgery, which is exactly what an **imperative** recipe (a | |
| 36 | +`Recipe` with a `JavaIsoVisitor`) is for. So it stays imperative — see | |
| 37 | +`InlineSingleUseLocalVariable.java`. | |
| 38 | + | |
| 39 | +## `InlineSingleUseLocalVariable` — the safety rules | |
| 40 | + | |
| 41 | +Inlines `T v = expr;` into its use **only** when all hold, so behaviour can never change: | |
| 42 | +1. Single-variable declaration with an initializer. | |
| 43 | +2. The initializer is a high-precedence expression (method call, `new`, field/array access, literal, | |
| 44 | + identifier, parenthesized) — safe to drop in without parentheses. | |
| 45 | +3. The variable is **read exactly once**. Two+ reads (e.g. `save(type); … type.getId()`) are never | |
| 46 | + inlined — that would evaluate the initializer twice. Zero reads are left alone (dead code). | |
| 47 | +4. That single read is in the **immediately following statement**, reached only through | |
| 48 | + side-effect-transparent nodes — so no reordering, and never inside a loop/lambda/conditional. | |
| 49 | + | |
| 50 | +## `ListGetFirst` — note | |
| 51 | + | |
| 52 | +On an empty list `get(0)` throws `IndexOutOfBoundsException` while `getFirst()` throws | |
| 53 | +`NoSuchElementException`; both mean "no first element". `getFirst()` comes from Java 21's | |
| 54 | +`SequencedCollection`. | |
| 55 | + | |
| 56 | +## Build & test | |
| 57 | + | |
| 58 | +```sh | |
| 59 | +cd refactoring-tools | |
| 60 | +mvn test # 17 tests: 13 for the imperative recipe, 4 for the Refaster one | |
| 61 | +mvn install # publishes victor.training.agentic:refactoring-tools:1.0 to your ~/.m2 | |
| 62 | +``` | |
| 63 | + | |
| 64 | +The Refaster recipe class `ListGetFirstRecipe` is **generated** at build time by the | |
| 65 | +`rewrite-templating` annotation processor from `ListGetFirst` (look in | |
| 66 | +`target/generated-sources/annotations/…` after a build). | |
| 67 | + | |
| 68 | +## Run against the backend — no backend pom changes | |
| 69 | + | |
| 70 | +Because nothing is wired into `petclinic-backend/pom.xml`, invoke the plugin ad-hoc and hand it this | |
| 71 | +module as a recipe artifact. Run from the backend directory: | |
| 72 | + | |
| 73 | +```sh | |
| 74 | +cd petclinic-backend | |
| 75 | + | |
| 76 | +# Preview only — writes target/rewrite/rewrite.patch, changes no sources: | |
| 77 | +mvn org.openrewrite.maven:rewrite-maven-plugin:6.44.0:dryRun \ | |
| 78 | + -Drewrite.recipeArtifactCoordinates=victor.training.agentic:refactoring-tools:1.0 \ | |
| 79 | + -Drewrite.activeRecipes=victor.training.petclinic.rewrite.ListGetFirstRecipe,victor.training.petclinic.rewrite.InlineSingleUseLocalVariable | |
| 80 | + | |
| 81 | +# Apply in place (review with `git diff`, undo with `git checkout .`): | |
| 82 | +mvn org.openrewrite.maven:rewrite-maven-plugin:6.44.0:run \ | |
| 83 | + -Drewrite.recipeArtifactCoordinates=victor.training.agentic:refactoring-tools:1.0 \ | |
| 84 | + -Drewrite.activeRecipes=victor.training.petclinic.rewrite.ListGetFirstRecipe,victor.training.petclinic.rewrite.InlineSingleUseLocalVariable | |
| 85 | +``` | |
| 86 | + | |
| 87 | +Drop either name from `-Drewrite.activeRecipes` to run just one recipe. | |
| 88 | + | |
| 89 | +### Watch the imperative recipe reproduce the exact hand-refactoring | |
| 90 | + | |
| 91 | +`getPetType` in `PetTypeRestController` is already inlined by hand, so put the variable back first and | |
| 92 | +let the recipe redo it: | |
| 93 | + | |
| 94 | +```sh | |
| 95 | +cd petclinic-clone | |
| 96 | +git checkout petclinic-backend/src/main/java/victor/training/petclinic/rest/PetTypeRestController.java | |
| 97 | +cd petclinic-backend | |
| 98 | +mvn org.openrewrite.maven:rewrite-maven-plugin:6.44.0:run \ | |
| 99 | + -Drewrite.recipeArtifactCoordinates=victor.training.agentic:refactoring-tools:1.0 \ | |
| 100 | + -Drewrite.activeRecipes=victor.training.petclinic.rewrite.InlineSingleUseLocalVariable | |
| 101 | +git diff # getPetType inlined again — by the recipe; `type` (2 uses) left untouched | |
| 102 | +``` |
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<project xmlns="http://maven.apache.org/POM/4.0.0" | |
| 3 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
| 4 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> | |
| 5 | + <modelVersion>4.0.0</modelVersion> | |
| 6 | + | |
| 7 | + <groupId>victor.training.agentic</groupId> | |
| 8 | + <artifactId>refactoring-tools</artifactId> | |
| 9 | + <version>1.0</version> | |
| 10 | + | |
| 11 | + <name>Refactoring Legacy</name> | |
| 12 | + <description> | |
| 13 | + OpenRewrite recipes that reproduce, programmatically, refactorings we do by hand in this repo. | |
| 14 | + Two flavours: | |
| 15 | + - InlineSingleUseLocalVariable : a full imperative recipe (needs data-flow, so it MUST be imperative) | |
| 16 | + - ListGetFirst : a Refaster template recipe (a simple before/after expression swap) | |
| 17 | + </description> | |
| 18 | + | |
| 19 | + <properties> | |
| 20 | + <maven.compiler.release>21</maven.compiler.release> | |
| 21 | + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> | |
| 22 | + | |
| 23 | + <rewrite-recipe-bom.version>3.35.0</rewrite-recipe-bom.version> | |
| 24 | + <rewrite-templating.version>1.42.0</rewrite-templating.version> | |
| 25 | + <error-prone.version>2.36.0</error-prone.version> | |
| 26 | + <junit.version>5.11.4</junit.version> | |
| 27 | + </properties> | |
| 28 | + | |
| 29 | + <dependencyManagement> | |
| 30 | + <dependencies> | |
| 31 | + <!-- Aligns the versions of every org.openrewrite.* artifact we use. --> | |
| 32 | + <dependency> | |
| 33 | + <groupId>org.openrewrite.recipe</groupId> | |
| 34 | + <artifactId>rewrite-recipe-bom</artifactId> | |
| 35 | + <version>${rewrite-recipe-bom.version}</version> | |
| 36 | + <type>pom</type> | |
| 37 | + <scope>import</scope> | |
| 38 | + </dependency> | |
| 39 | + <!-- Forces a single JUnit 5 platform/jupiter version across the whole test classpath | |
| 40 | + (rewrite-test and junit-jupiter otherwise drag in mismatched platform jars). --> | |
| 41 | + <dependency> | |
| 42 | + <groupId>org.junit</groupId> | |
| 43 | + <artifactId>junit-bom</artifactId> | |
| 44 | + <version>${junit.version}</version> | |
| 45 | + <type>pom</type> | |
| 46 | + <scope>import</scope> | |
| 47 | + </dependency> | |
| 48 | + </dependencies> | |
| 49 | + </dependencyManagement> | |
| 50 | + | |
| 51 | + <dependencies> | |
| 52 | + <!-- The Java LST model + visitor API we write the recipe against. --> | |
| 53 | + <dependency> | |
| 54 | + <groupId>org.openrewrite</groupId> | |
| 55 | + <artifactId>rewrite-java</artifactId> | |
| 56 | + </dependency> | |
| 57 | + | |
| 58 | + <!-- ===== Refaster template support (the "refactor recipe" flavour) ===== --> | |
| 59 | + <!-- The annotation processor that turns @BeforeTemplate/@AfterTemplate classes into recipes. --> | |
| 60 | + <dependency> | |
| 61 | + <groupId>org.openrewrite</groupId> | |
| 62 | + <artifactId>rewrite-templating</artifactId> | |
| 63 | + </dependency> | |
| 64 | + <!-- Supplies the @BeforeTemplate / @AfterTemplate annotations only (compile-time). --> | |
| 65 | + <dependency> | |
| 66 | + <groupId>com.google.errorprone</groupId> | |
| 67 | + <artifactId>error_prone_core</artifactId> | |
| 68 | + <version>${error-prone.version}</version> | |
| 69 | + <scope>provided</scope> | |
| 70 | + <exclusions> | |
| 71 | + <exclusion> | |
| 72 | + <groupId>com.google.auto.service</groupId> | |
| 73 | + <artifactId>auto-service-annotations</artifactId> | |
| 74 | + </exclusion> | |
| 75 | + </exclusions> | |
| 76 | + </dependency> | |
| 77 | + <!-- Provides jakarta.annotation.Generated (javax.annotation.Generated was removed in Java 9+). --> | |
| 78 | + <dependency> | |
| 79 | + <groupId>jakarta.annotation</groupId> | |
| 80 | + <artifactId>jakarta.annotation-api</artifactId> | |
| 81 | + <version>3.0.0</version> | |
| 82 | + </dependency> | |
| 83 | + | |
| 84 | + <!-- RewriteTest harness: before/after fixtures with full type attribution. --> | |
| 85 | + <dependency> | |
| 86 | + <groupId>org.openrewrite</groupId> | |
| 87 | + <artifactId>rewrite-test</artifactId> | |
| 88 | + <scope>test</scope> | |
| 89 | + </dependency> | |
| 90 | + <!-- Java 21 parser backend used by the test harness to build typed LSTs. --> | |
| 91 | + <dependency> | |
| 92 | + <groupId>org.openrewrite</groupId> | |
| 93 | + <artifactId>rewrite-java-21</artifactId> | |
| 94 | + <scope>test</scope> | |
| 95 | + </dependency> | |
| 96 | + <dependency> | |
| 97 | + <groupId>org.junit.jupiter</groupId> | |
| 98 | + <artifactId>junit-jupiter</artifactId> | |
| 99 | + <scope>test</scope> | |
| 100 | + </dependency> | |
| 101 | + </dependencies> | |
| 102 | + | |
| 103 | + <build> | |
| 104 | + <plugins> | |
| 105 | + <plugin> | |
| 106 | + <groupId>org.apache.maven.plugins</groupId> | |
| 107 | + <artifactId>maven-compiler-plugin</artifactId> | |
| 108 | + <version>3.15.0</version> | |
| 109 | + <configuration> | |
| 110 | + <!-- keep parameter names so Refaster templates read naturally --> | |
| 111 | + <compilerArgs> | |
| 112 | + <arg>-parameters</arg> | |
| 113 | + <!-- generated recipe classes use jakarta.annotation.Generated on Java 9+ --> | |
| 114 | + <arg>-Arewrite.generatedAnnotation=jakarta.annotation.Generated</arg> | |
| 115 | + </compilerArgs> | |
| 116 | + <annotationProcessorPaths> | |
| 117 | + <!-- generates <TemplateClass>Recipe from @BeforeTemplate/@AfterTemplate --> | |
| 118 | + <path> | |
| 119 | + <groupId>org.openrewrite</groupId> | |
| 120 | + <artifactId>rewrite-templating</artifactId> | |
| 121 | + <version>${rewrite-templating.version}</version> | |
| 122 | + </path> | |
| 123 | + </annotationProcessorPaths> | |
| 124 | + </configuration> | |
| 125 | + </plugin> | |
| 126 | + <plugin> | |
| 127 | + <groupId>org.apache.maven.plugins</groupId> | |
| 128 | + <artifactId>maven-surefire-plugin</artifactId> | |
| 129 | + <version>3.5.2</version> | |
| 130 | + </plugin> | |
| 131 | + </plugins> | |
| 132 | + </build> | |
| 133 | +</project> |
| 1 | +package victor.training.petclinic.rewrite; | |
| 2 | + | |
| 3 | +import org.openrewrite.Cursor; | |
| 4 | +import org.openrewrite.ExecutionContext; | |
| 5 | +import org.openrewrite.Recipe; | |
| 6 | +import org.openrewrite.TreeVisitor; | |
| 7 | +import org.openrewrite.java.JavaIsoVisitor; | |
| 8 | +import org.openrewrite.java.JavaVisitor; | |
| 9 | +import org.openrewrite.java.tree.Expression; | |
| 10 | +import org.openrewrite.java.tree.J; | |
| 11 | +import org.openrewrite.java.tree.JavaType; | |
| 12 | +import org.openrewrite.java.tree.Statement; | |
| 13 | + | |
| 14 | +import java.util.ArrayList; | |
| 15 | +import java.util.List; | |
| 16 | + | |
| 17 | +/** | |
| 18 | + * Inlines a local variable into its single use, e.g. | |
| 19 | + * | |
| 20 | + * <pre>{@code | |
| 21 | + * PetType petType = petTypeRepository.findById(petTypeId).orElseThrow(); | |
| 22 | + * return petTypeMapper.toPetTypeDto(petType); | |
| 23 | + * }</pre> | |
| 24 | + * | |
| 25 | + * becomes | |
| 26 | + * | |
| 27 | + * <pre>{@code | |
| 28 | + * return petTypeMapper.toPetTypeDto(petTypeRepository.findById(petTypeId).orElseThrow()); | |
| 29 | + * }</pre> | |
| 30 | + * | |
| 31 | + * <h2>Why the guards matter</h2> | |
| 32 | + * The whole point of the recipe is to <em>never</em> evaluate the initializer more than once. | |
| 33 | + * A variable therefore qualifies only when ALL of the following hold: | |
| 34 | + * <ol> | |
| 35 | + * <li>It is a single-variable declaration with an initializer.</li> | |
| 36 | + * <li>The initializer is a high-precedence expression (method call, {@code new}, field/array | |
| 37 | + * access, literal, identifier or a parenthesized expression) so it can be dropped verbatim | |
| 38 | + * into another expression without needing parentheses and without changing operator precedence.</li> | |
| 39 | + * <li>The variable is <strong>read exactly once</strong> in its scope. Two or more uses (as in | |
| 40 | + * {@code save(type); ...; type.getId()}) are left untouched, because inlining would re-evaluate | |
| 41 | + * the initializer. Zero uses are left untouched too (that is a different concern: dead code).</li> | |
| 42 | + * <li>That single read lives inside the <strong>immediately following statement</strong> and is reached | |
| 43 | + * only through side-effect-transparent expression nodes. This guarantees the initializer runs | |
| 44 | + * exactly once, unconditionally, in the same order as before — no reordering across intervening | |
| 45 | + * statements, and never inside a loop, lambda, conditional or short-circuit that could repeat, | |
| 46 | + * defer or skip it.</li> | |
| 47 | + * </ol> | |
| 48 | + */ | |
| 49 | +public class InlineSingleUseLocalVariable extends Recipe { | |
| 50 | + | |
| 51 | + @Override | |
| 52 | + public String getDisplayName() { | |
| 53 | + return "Inline a local variable used exactly once"; | |
| 54 | + } | |
| 55 | + | |
| 56 | + @Override | |
| 57 | + public String getDescription() { | |
| 58 | + return "Replaces the single read of a local variable with its initializer and removes the " + | |
| 59 | + "declaration, but only when the variable is used exactly once so the initializer is " + | |
| 60 | + "never evaluated more than once. Variables used two or more times are left as-is."; | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Override | |
| 64 | + public TreeVisitor<?, ExecutionContext> getVisitor() { | |
| 65 | + return new JavaIsoVisitor<ExecutionContext>() { | |
| 66 | + @Override | |
| 67 | + public J.Block visitBlock(J.Block block, ExecutionContext ctx) { | |
| 68 | + J.Block b = super.visitBlock(block, ctx); | |
| 69 | + List<Statement> statements = b.getStatements(); | |
| 70 | + | |
| 71 | + // Inline at most one variable per pass; the recipe re-runs to convergence, which keeps | |
| 72 | + // chained/interdependent inlines from corrupting each other within a single pass. | |
| 73 | + for (int i = 0; i + 1 < statements.size(); i++) { | |
| 74 | + if (!(statements.get(i) instanceof J.VariableDeclarations vd)) { | |
| 75 | + continue; | |
| 76 | + } | |
| 77 | + if (vd.getVariables().size() != 1) { | |
| 78 | + continue; // keep it simple: only single-variable declarations | |
| 79 | + } | |
| 80 | + J.VariableDeclarations.NamedVariable declared = vd.getVariables().get(0); | |
| 81 | + Expression initializer = declared.getInitializer(); | |
| 82 | + JavaType.Variable declaredType = declared.getVariableType(); | |
| 83 | + if (initializer == null || declaredType == null || !isSafeToInline(initializer)) { | |
| 84 | + continue; | |
| 85 | + } | |
| 86 | + | |
| 87 | + List<Cursor> reads = findReferences(b, declaredType, declared.getName()); | |
| 88 | + if (reads.size() != 1) { | |
| 89 | + continue; // 0 uses (dead code) or 2+ uses (would re-evaluate) => leave alone | |
| 90 | + } | |
| 91 | + Cursor use = reads.get(0); | |
| 92 | + if (isWriteTarget(use)) { | |
| 93 | + continue; // the single mention is an assignment/increment target, not a read | |
| 94 | + } | |
| 95 | + | |
| 96 | + Statement nextStatement = statements.get(i + 1); | |
| 97 | + if (!executesExactlyOnceWithin(use, nextStatement)) { | |
| 98 | + continue; | |
| 99 | + } | |
| 100 | + | |
| 101 | + Statement inlinedNext = (Statement) new ReplaceIdentifier(use.getValue(), initializer) | |
| 102 | + .visitNonNull(nextStatement, ctx); | |
| 103 | + List<Statement> rewritten = new ArrayList<>(statements); | |
| 104 | + rewritten.set(i + 1, inlinedNext); | |
| 105 | + rewritten.remove(i); | |
| 106 | + return b.withStatements(rewritten); | |
| 107 | + } | |
| 108 | + return b; | |
| 109 | + } | |
| 110 | + }; | |
| 111 | + } | |
| 112 | + | |
| 113 | + /** | |
| 114 | + * Only inline initializers that are "primary"/postfix expressions: they carry the highest | |
| 115 | + * precedence in Java, so substituting them into any surrounding expression cannot change how that | |
| 116 | + * expression parses and never needs extra parentheses. | |
| 117 | + */ | |
| 118 | + private static boolean isSafeToInline(Expression e) { | |
| 119 | + return e instanceof J.MethodInvocation | |
| 120 | + || e instanceof J.NewClass | |
| 121 | + || e instanceof J.NewArray | |
| 122 | + || e instanceof J.FieldAccess | |
| 123 | + || e instanceof J.ArrayAccess | |
| 124 | + || e instanceof J.Identifier | |
| 125 | + || e instanceof J.Literal | |
| 126 | + || e instanceof J.Parentheses; | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** Collect every read/write mention of {@code target} inside {@code scope}, excluding the declaration name. */ | |
| 130 | + private static List<Cursor> findReferences(J scope, JavaType.Variable target, J.Identifier declarationName) { | |
| 131 | + List<Cursor> found = new ArrayList<>(); | |
| 132 | + new JavaIsoVisitor<List<Cursor>>() { | |
| 133 | + @Override | |
| 134 | + public J.Identifier visitIdentifier(J.Identifier identifier, List<Cursor> out) { | |
| 135 | + if (identifier != declarationName && identifier.getFieldType() == target) { | |
| 136 | + out.add(getCursor()); | |
| 137 | + } | |
| 138 | + return identifier; | |
| 139 | + } | |
| 140 | + }.visit(scope, found); | |
| 141 | + return found; | |
| 142 | + } | |
| 143 | + | |
| 144 | + /** True when the single mention is the target of an assignment or an increment/decrement (a write, not a read). */ | |
| 145 | + private static boolean isWriteTarget(Cursor identifierCursor) { | |
| 146 | + J.Identifier id = identifierCursor.getValue(); | |
| 147 | + Object parent = identifierCursor.getParentTreeCursor().getValue(); | |
| 148 | + if (parent instanceof J.Assignment assignment) { | |
| 149 | + return assignment.getVariable() == id; | |
| 150 | + } | |
| 151 | + if (parent instanceof J.AssignmentOperation assignmentOperation) { | |
| 152 | + return assignmentOperation.getVariable() == id; | |
| 153 | + } | |
| 154 | + if (parent instanceof J.Unary unary) { | |
| 155 | + return switch (unary.getOperator()) { | |
| 156 | + case PreIncrement, PreDecrement, PostIncrement, PostDecrement -> unary.getExpression() == id; | |
| 157 | + default -> false; | |
| 158 | + }; | |
| 159 | + } | |
| 160 | + return false; | |
| 161 | + } | |
| 162 | + | |
| 163 | + /** | |
| 164 | + * True when {@code use} sits inside {@code statement} and is reached only through | |
| 165 | + * side-effect-transparent expression nodes — i.e. it is guaranteed to run exactly once, | |
| 166 | + * unconditionally, whenever {@code statement} runs. Any loop, lambda, nested class, conditional, | |
| 167 | + * ternary or short-circuit operator on the path makes this return false. | |
| 168 | + */ | |
| 169 | + private static boolean executesExactlyOnceWithin(Cursor use, Statement statement) { | |
| 170 | + Cursor c = use.getParentTreeCursor(); | |
| 171 | + while (true) { | |
| 172 | + Object node = c.getValue(); | |
| 173 | + if (node == statement) { | |
| 174 | + return isUnconditionalStatement(statement); | |
| 175 | + } | |
| 176 | + if (!isTransparentExpression(node)) { | |
| 177 | + return false; | |
| 178 | + } | |
| 179 | + c = c.getParentTreeCursor(); | |
| 180 | + if (c.getValue() == Cursor.ROOT_VALUE) { | |
| 181 | + return false; // walked past the statement without meeting it | |
| 182 | + } | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + /** Statements whose (relevant) sub-expressions run unconditionally exactly once when the statement runs. */ | |
| 187 | + private static boolean isUnconditionalStatement(Statement statement) { | |
| 188 | + return statement instanceof J.Return | |
| 189 | + || statement instanceof J.Throw | |
| 190 | + || statement instanceof J.MethodInvocation | |
| 191 | + || statement instanceof J.NewClass | |
| 192 | + || statement instanceof J.Assignment | |
| 193 | + || statement instanceof J.AssignmentOperation | |
| 194 | + || statement instanceof J.Unary | |
| 195 | + || statement instanceof J.VariableDeclarations; | |
| 196 | + } | |
| 197 | + | |
| 198 | + /** | |
| 199 | + * Expression nodes we are willing to walk <em>through</em> from the statement down to the use: | |
| 200 | + * each evaluates its children unconditionally and exactly once. Deliberately excludes | |
| 201 | + * {@link J.Ternary}, short-circuit {@link J.Binary} (&&, ||), lambdas, and anonymous classes. | |
| 202 | + */ | |
| 203 | + private static boolean isTransparentExpression(Object node) { | |
| 204 | + if (node instanceof J.MethodInvocation | |
| 205 | + || node instanceof J.NewClass | |
| 206 | + || node instanceof J.NewArray | |
| 207 | + || node instanceof J.FieldAccess | |
| 208 | + || node instanceof J.ArrayAccess | |
| 209 | + || node instanceof J.Parentheses | |
| 210 | + || node instanceof J.ControlParentheses | |
| 211 | + || node instanceof J.TypeCast | |
| 212 | + || node instanceof J.InstanceOf) { | |
| 213 | + return true; | |
| 214 | + } | |
| 215 | + if (node instanceof J.Unary unary) { | |
| 216 | + return switch (unary.getOperator()) { | |
| 217 | + case PreIncrement, PreDecrement, PostIncrement, PostDecrement -> false; | |
| 218 | + default -> true; | |
| 219 | + }; | |
| 220 | + } | |
| 221 | + if (node instanceof J.Binary binary) { | |
| 222 | + return switch (binary.getOperator()) { | |
| 223 | + case And, Or -> false; // short-circuit: right operand is conditional | |
| 224 | + default -> true; | |
| 225 | + }; | |
| 226 | + } | |
| 227 | + return false; | |
| 228 | + } | |
| 229 | + | |
| 230 | + /** Swaps a specific identifier instance for an expression, preserving the identifier's whitespace. */ | |
| 231 | + private static final class ReplaceIdentifier extends JavaVisitor<ExecutionContext> { | |
| 232 | + private final J.Identifier target; | |
| 233 | + private final Expression replacement; | |
| 234 | + | |
| 235 | + private ReplaceIdentifier(J.Identifier target, Expression replacement) { | |
| 236 | + this.target = target; | |
| 237 | + this.replacement = replacement; | |
| 238 | + } | |
| 239 | + | |
| 240 | + @Override | |
| 241 | + public J visitIdentifier(J.Identifier identifier, ExecutionContext ctx) { | |
| 242 | + if (identifier == target) { | |
| 243 | + return replacement.withPrefix(identifier.getPrefix()); | |
| 244 | + } | |
| 245 | + return identifier; | |
| 246 | + } | |
| 247 | + } | |
| 248 | +} |
| 1 | +package victor.training.petclinic.rewrite; | |
| 2 | + | |
| 3 | +import com.google.errorprone.refaster.annotation.AfterTemplate; | |
| 4 | +import com.google.errorprone.refaster.annotation.BeforeTemplate; | |
| 5 | +import org.openrewrite.java.template.RecipeDescriptor; | |
| 6 | + | |
| 7 | +import java.util.List; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * A <strong>Refaster template</strong> recipe: the whole recipe is just a before/after pair of code | |
| 11 | + * snippets. The {@code rewrite-templating} annotation processor reads this class at build time and | |
| 12 | + * generates the actual recipe class {@code ListGetFirstRecipe} (class name + {@code Recipe} because | |
| 13 | + * there is a single template). | |
| 14 | + * | |
| 15 | + * <p>It replaces {@code list.get(0)} with the Java 21 {@code list.getFirst()} (from | |
| 16 | + * {@link java.util.SequencedCollection}). Because Refaster works on a typed template, it only matches | |
| 17 | + * when the receiver is really a {@link List} — {@code someArray[0]} or a {@code get(0)} on an | |
| 18 | + * unrelated type is left alone. Compare with {@code InlineSingleUseLocalVariable}: that one cannot be | |
| 19 | + * a Refaster template because it must count references and delete a statement — analysis a fixed | |
| 20 | + * before/after snippet cannot express.</p> | |
| 21 | + */ | |
| 22 | +@RecipeDescriptor( | |
| 23 | + name = "Use `List#getFirst()` instead of `list.get(0)`", | |
| 24 | + description = "Replaces `list.get(0)` with `list.getFirst()` (Java 21 SequencedCollection), which reads " + | |
| 25 | + "more clearly. Note: on an empty list `get(0)` throws IndexOutOfBoundsException while " + | |
| 26 | + "`getFirst()` throws NoSuchElementException — both signal \"no first element\"." | |
| 27 | +) | |
| 28 | +public class ListGetFirst<T> { | |
| 29 | + | |
| 30 | + @BeforeTemplate | |
| 31 | + T getZero(List<T> list) { | |
| 32 | + return list.get(0); | |
| 33 | + } | |
| 34 | + | |
| 35 | + @AfterTemplate | |
| 36 | + T getFirst(List<T> list) { | |
| 37 | + return list.getFirst(); | |
| 38 | + } | |
| 39 | +} |
| 1 | +package victor.training.petclinic.rewrite; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.Test; | |
| 4 | +import org.openrewrite.test.RecipeSpec; | |
| 5 | +import org.openrewrite.test.RewriteTest; | |
| 6 | + | |
| 7 | +import static org.openrewrite.java.Assertions.java; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Corner cases mirror the real methods in {@code PetTypeRestController}: | |
| 11 | + * <ul> | |
| 12 | + * <li>{@code getPetType}/{@code listPetTypes} -> single use, inline (the manual refactoring)</li> | |
| 13 | + * <li>{@code deletePetType} -> single use in a following call statement, inline</li> | |
| 14 | + * <li>{@code addPetType} (save + getId) -> two uses, MUST NOT inline</li> | |
| 15 | + * <li>{@code updatePetType} (setName + save) -> two uses, MUST NOT inline</li> | |
| 16 | + * </ul> | |
| 17 | + * plus the tricky ones a naive recipe would get wrong. | |
| 18 | + */ | |
| 19 | +class InlineSingleUseLocalVariableTest implements RewriteTest { | |
| 20 | + | |
| 21 | + @Override | |
| 22 | + public void defaults(RecipeSpec spec) { | |
| 23 | + spec.recipe(new InlineSingleUseLocalVariable()); | |
| 24 | + } | |
| 25 | + | |
| 26 | + // ---------- cases that SHOULD inline ---------- | |
| 27 | + | |
| 28 | + @Test | |
| 29 | + void inlinesSingleUseIntoReturn() { // == getPetType / listPetTypes | |
| 30 | + rewriteRun(java( | |
| 31 | + """ | |
| 32 | + class A { | |
| 33 | + String find(int id) { return "" + id; } | |
| 34 | + String toDto(String s) { return s; } | |
| 35 | + String get(int id) { | |
| 36 | + String found = find(id); | |
| 37 | + return toDto(found); | |
| 38 | + } | |
| 39 | + } | |
| 40 | + """, | |
| 41 | + """ | |
| 42 | + class A { | |
| 43 | + String find(int id) { return "" + id; } | |
| 44 | + String toDto(String s) { return s; } | |
| 45 | + String get(int id) { | |
| 46 | + return toDto(find(id)); | |
| 47 | + } | |
| 48 | + } | |
| 49 | + """ | |
| 50 | + )); | |
| 51 | + } | |
| 52 | + | |
| 53 | + @Test | |
| 54 | + void inlinesNewClassInitializer() { // == listPetTypes: new ArrayList<>(...) | |
| 55 | + rewriteRun(java( | |
| 56 | + """ | |
| 57 | + import java.util.ArrayList; | |
| 58 | + import java.util.List; | |
| 59 | + class A { | |
| 60 | + List<String> all() { return new ArrayList<>(); } | |
| 61 | + List<String> toDtos(List<String> in) { return in; } | |
| 62 | + List<String> list() { | |
| 63 | + List<String> items = new ArrayList<>(all()); | |
| 64 | + return toDtos(items); | |
| 65 | + } | |
| 66 | + } | |
| 67 | + """, | |
| 68 | + """ | |
| 69 | + import java.util.ArrayList; | |
| 70 | + import java.util.List; | |
| 71 | + class A { | |
| 72 | + List<String> all() { return new ArrayList<>(); } | |
| 73 | + List<String> toDtos(List<String> in) { return in; } | |
| 74 | + List<String> list() { | |
| 75 | + return toDtos(new ArrayList<>(all())); | |
| 76 | + } | |
| 77 | + } | |
| 78 | + """ | |
| 79 | + )); | |
| 80 | + } | |
| 81 | + | |
| 82 | + @Test | |
| 83 | + void inlinesIntoFollowingCallStatement() { // == deletePetType (use is a plain call, more stmts follow) | |
| 84 | + rewriteRun(java( | |
| 85 | + """ | |
| 86 | + class A { | |
| 87 | + String find(int id) { return "" + id; } | |
| 88 | + void delete(String s) {} | |
| 89 | + void remove(int id) { | |
| 90 | + String found = find(id); | |
| 91 | + delete(found); | |
| 92 | + } | |
| 93 | + } | |
| 94 | + """, | |
| 95 | + """ | |
| 96 | + class A { | |
| 97 | + String find(int id) { return "" + id; } | |
| 98 | + void delete(String s) {} | |
| 99 | + void remove(int id) { | |
| 100 | + delete(find(id)); | |
| 101 | + } | |
| 102 | + } | |
| 103 | + """ | |
| 104 | + )); | |
| 105 | + } | |
| 106 | + | |
| 107 | + @Test | |
| 108 | + void inlinesTwoIndependentVariablesAcrossCycles() { | |
| 109 | + rewriteRun( | |
| 110 | + spec -> spec.cycles(2).expectedCyclesThatMakeChanges(2), | |
| 111 | + java( | |
| 112 | + """ | |
| 113 | + class A { | |
| 114 | + String find(int id) { return "" + id; } | |
| 115 | + String toDto(String s) { return s; } | |
| 116 | + void save(String s) {} | |
| 117 | + void two(int id) { | |
| 118 | + String a = find(id); | |
| 119 | + save(toDto(a)); | |
| 120 | + String b = find(id); | |
| 121 | + save(toDto(b)); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + """, | |
| 125 | + """ | |
| 126 | + class A { | |
| 127 | + String find(int id) { return "" + id; } | |
| 128 | + String toDto(String s) { return s; } | |
| 129 | + void save(String s) {} | |
| 130 | + void two(int id) { | |
| 131 | + save(toDto(find(id))); | |
| 132 | + save(toDto(find(id))); | |
| 133 | + } | |
| 134 | + } | |
| 135 | + """ | |
| 136 | + ) | |
| 137 | + ); | |
| 138 | + } | |
| 139 | + | |
| 140 | + // ---------- cases that MUST NOT inline (the whole point) ---------- | |
| 141 | + | |
| 142 | + @Test | |
| 143 | + void keepsVariableUsedTwice() { // == addPetType: save(type) + type.getId() | |
| 144 | + rewriteRun(java( | |
| 145 | + """ | |
| 146 | + class A { | |
| 147 | + String make() { return "x"; } | |
| 148 | + void save(String s) {} | |
| 149 | + void link(String s) {} | |
| 150 | + void add() { | |
| 151 | + String type = make(); | |
| 152 | + save(type); | |
| 153 | + link(type); | |
| 154 | + } | |
| 155 | + } | |
| 156 | + """ | |
| 157 | + )); | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Test | |
| 161 | + void keepsVariableUsedAsReceiverThenArgument() { // == updatePetType: setName + save | |
| 162 | + rewriteRun(java( | |
| 163 | + """ | |
| 164 | + class A { | |
| 165 | + A find(int id) { return this; } | |
| 166 | + void setName(String n) {} | |
| 167 | + void save(A a) {} | |
| 168 | + void update(int id, String name) { | |
| 169 | + A current = find(id); | |
| 170 | + current.setName(name); | |
| 171 | + save(current); | |
| 172 | + } | |
| 173 | + } | |
| 174 | + """ | |
| 175 | + )); | |
| 176 | + } | |
| 177 | + | |
| 178 | + @Test | |
| 179 | + void keepsUnusedVariable() { // 0 uses -> dead code, not our concern | |
| 180 | + rewriteRun(java( | |
| 181 | + """ | |
| 182 | + class A { | |
| 183 | + String make() { return "x"; } | |
| 184 | + void doThing() { | |
| 185 | + String unused = make(); | |
| 186 | + System.out.println("hi"); | |
| 187 | + } | |
| 188 | + } | |
| 189 | + """ | |
| 190 | + )); | |
| 191 | + } | |
| 192 | + | |
| 193 | + @Test | |
| 194 | + void keepsVariableWhoseOnlyMentionIsAReassignment() { // single mention, but it is a write | |
| 195 | + rewriteRun(java( | |
| 196 | + """ | |
| 197 | + class A { | |
| 198 | + String make() { return "x"; } | |
| 199 | + String other() { return "y"; } | |
| 200 | + void doThing() { | |
| 201 | + String s = make(); | |
| 202 | + s = other(); | |
| 203 | + } | |
| 204 | + } | |
| 205 | + """ | |
| 206 | + )); | |
| 207 | + } | |
| 208 | + | |
| 209 | + @Test | |
| 210 | + void keepsWhenUseIsNotTheAdjacentStatement() { // reordering across an intervening statement is unsafe | |
| 211 | + rewriteRun(java( | |
| 212 | + """ | |
| 213 | + class A { | |
| 214 | + String find(int id) { return "" + id; } | |
| 215 | + void log(String m) {} | |
| 216 | + String toDto(String s) { return s; } | |
| 217 | + String get(int id) { | |
| 218 | + String found = find(id); | |
| 219 | + log("getting"); | |
| 220 | + return toDto(found); | |
| 221 | + } | |
| 222 | + } | |
| 223 | + """ | |
| 224 | + )); | |
| 225 | + } | |
| 226 | + | |
| 227 | + @Test | |
| 228 | + void keepsWhenSingleUseIsInsideALoop() { // would evaluate the initializer many times | |
| 229 | + rewriteRun(java( | |
| 230 | + """ | |
| 231 | + class A { | |
| 232 | + String compute() { return "p"; } | |
| 233 | + void use(String s) {} | |
| 234 | + void go() { | |
| 235 | + String prefix = compute(); | |
| 236 | + for (int i = 0; i < 3; i++) { | |
| 237 | + use(prefix); | |
| 238 | + } | |
| 239 | + } | |
| 240 | + } | |
| 241 | + """ | |
| 242 | + )); | |
| 243 | + } | |
| 244 | + | |
| 245 | + @Test | |
| 246 | + void keepsWhenSingleUseIsInsideALambda() { // would defer / repeat evaluation | |
| 247 | + rewriteRun(java( | |
| 248 | + """ | |
| 249 | + class A { | |
| 250 | + String compute() { return "p"; } | |
| 251 | + void use(String s) {} | |
| 252 | + Runnable make() { | |
| 253 | + String p = compute(); | |
| 254 | + return () -> use(p); | |
| 255 | + } | |
| 256 | + } | |
| 257 | + """ | |
| 258 | + )); | |
| 259 | + } | |
| 260 | + | |
| 261 | + @Test | |
| 262 | + void keepsPrecedenceUnsafeBinaryInitializer() { // x + 1 inlined into r * 2 would misparse | |
| 263 | + rewriteRun(java( | |
| 264 | + """ | |
| 265 | + class A { | |
| 266 | + int compute(int x) { | |
| 267 | + int sum = x + 1; | |
| 268 | + return sum * 2; | |
| 269 | + } | |
| 270 | + } | |
| 271 | + """ | |
| 272 | + )); | |
| 273 | + } | |
| 274 | + | |
| 275 | + @Test | |
| 276 | + void keepsTernaryInitializer() { | |
| 277 | + rewriteRun(java( | |
| 278 | + """ | |
| 279 | + class A { | |
| 280 | + int pick(boolean b, int x, int y) { | |
| 281 | + int r = b ? x : y; | |
| 282 | + return r + 1; | |
| 283 | + } | |
| 284 | + } | |
| 285 | + """ | |
| 286 | + )); | |
| 287 | + } | |
| 288 | +} |
| 1 | +package victor.training.petclinic.rewrite; | |
| 2 | + | |
| 3 | +import org.junit.jupiter.api.Test; | |
| 4 | +import org.openrewrite.test.RecipeSpec; | |
| 5 | +import org.openrewrite.test.RewriteTest; | |
| 6 | + | |
| 7 | +import static org.openrewrite.java.Assertions.java; | |
| 8 | + | |
| 9 | +/** | |
| 10 | + * Tests the recipe that the {@code rewrite-templating} processor generates from {@link ListGetFirst}. | |
| 11 | + * The generated class is {@code ListGetFirstRecipe} (single template -> "...Recipe"). | |
| 12 | + */ | |
| 13 | +class ListGetFirstTest implements RewriteTest { | |
| 14 | + | |
| 15 | + @Override | |
| 16 | + public void defaults(RecipeSpec spec) { | |
| 17 | + spec.recipe(new ListGetFirstRecipe()); | |
| 18 | + } | |
| 19 | + | |
| 20 | + @Test | |
| 21 | + void rewritesGetZeroToGetFirst() { | |
| 22 | + rewriteRun(java( | |
| 23 | + """ | |
| 24 | + import java.util.List; | |
| 25 | + class A { | |
| 26 | + String first(List<String> items) { | |
| 27 | + return items.get(0); | |
| 28 | + } | |
| 29 | + } | |
| 30 | + """, | |
| 31 | + """ | |
| 32 | + import java.util.List; | |
| 33 | + class A { | |
| 34 | + String first(List<String> items) { | |
| 35 | + return items.getFirst(); | |
| 36 | + } | |
| 37 | + } | |
| 38 | + """ | |
| 39 | + )); | |
| 40 | + } | |
| 41 | + | |
| 42 | + @Test | |
| 43 | + void rewritesGetZeroOnAChainedReceiver() { | |
| 44 | + rewriteRun(java( | |
| 45 | + """ | |
| 46 | + import java.util.List; | |
| 47 | + class Owner { List<String> getPets() { return List.of(); } } | |
| 48 | + class A { | |
| 49 | + String firstPet(Owner owner) { | |
| 50 | + return owner.getPets().get(0); | |
| 51 | + } | |
| 52 | + } | |
| 53 | + """, | |
| 54 | + """ | |
| 55 | + import java.util.List; | |
| 56 | + class Owner { List<String> getPets() { return List.of(); } } | |
| 57 | + class A { | |
| 58 | + String firstPet(Owner owner) { | |
| 59 | + return owner.getPets().getFirst(); | |
| 60 | + } | |
| 61 | + } | |
| 62 | + """ | |
| 63 | + )); | |
| 64 | + } | |
| 65 | + | |
| 66 | + @Test | |
| 67 | + void leavesOtherIndexesAlone() { | |
| 68 | + rewriteRun(java( | |
| 69 | + """ | |
| 70 | + import java.util.List; | |
| 71 | + class A { | |
| 72 | + String second(List<String> items) { | |
| 73 | + return items.get(1); | |
| 74 | + } | |
| 75 | + } | |
| 76 | + """ | |
| 77 | + )); | |
| 78 | + } | |
| 79 | + | |
| 80 | + @Test | |
| 81 | + void leavesNonListGetAlone() { | |
| 82 | + rewriteRun(java( | |
| 83 | + """ | |
| 84 | + import java.util.Map; | |
| 85 | + class A { | |
| 86 | + String lookup(Map<Integer, String> m) { | |
| 87 | + return m.get(0); | |
| 88 | + } | |
| 89 | + } | |
| 90 | + """ | |
| 91 | + )); | |
| 92 | + } | |
| 93 | +} |
| if [[ -f "$AGENT_JAR" ]]; then | ||
| 47 | 47 | export OTEL_METRICS_EXPORTER=otlp |
| 48 | 48 | export OTEL_TRACES_EXPORTER=otlp |
| 49 | 49 | export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local |
| 50 | + # Behind :4318 is either Grafana LGTM (./start-grafana.sh), which takes everything, or a | |
| 51 | + # bare Tempo (./start-tempo.sh), which only takes traces and would answer every logs and | |
| 52 | + # metrics export with a 404 logged by the agent. | |
| 53 | + if ! curl -fsS -o /dev/null --max-time 2 http://localhost:3300/api/health 2>/dev/null; then | |
| 54 | + export OTEL_LOGS_EXPORTER=none OTEL_METRICS_EXPORTER=none | |
| 55 | + echo "ℹ️ No Grafana on :3300 — exporting traces only (Tempo alone)." | |
| 56 | + fi | |
| 50 | 57 | # Capture the *most* detail the agent can give; what a sequence diagram |
| 51 | 58 | # actually shows is decided at render time (petclinic-test SEQ_SQL=…), so a |
| 52 | 59 | # different level of detail never costs another test run. |
| if [[ -f "$AGENT_JAR" ]]; then | ||
| 60 | 67 | export OTEL_SEMCONV_STABILITY_OPT_IN=database/dup |
| 61 | 68 | OTEL_JVM_ARGS="-javaagent:$AGENT_JAR" |
| 62 | 69 | else |
| 63 | - echo "ℹ️ OTel collector not running on :4318 — telemetry disabled. Run ./start-grafana.sh to enable." | |
| 70 | + echo "ℹ️ OTel collector not running on :4318 — telemetry disabled. Run ./start-grafana.sh (or ./start-tempo.sh, no Docker) to enable." | |
| 64 | 71 | fi |
| 65 | 72 | fi |
| 66 | 73 |
| 1 | 1 | #!/bin/bash |
| 2 | 2 | # The whole PetClinic in containers, one isolated instance per branch under review. |
| 3 | 3 | # |
| 4 | -# Inside the network every service keeps its default port (5432 / 8080 / 4200). Only the | |
| 5 | -# frontend is published, and the host picks the port, so instances never collide — and | |
| 6 | -# neither does an instance with the plain ./start-*.sh stack on :4200 and :5432. | |
| 4 | +# Inside the network every service keeps its default port (5432 / 8080 / 4200). What is | |
| 5 | +# published (frontend, backend, database) goes to a port the host picks, on loopback, so | |
| 6 | +# instances never collide — and neither does one with the plain ./start-*.sh stack on | |
| 7 | +# :4200 and :5432. | |
| 7 | 8 | # |
| 8 | 9 | # ./start-docker.sh up [--ref SHA] [--name N] [--ttl SECS] [--fresh] [--jacoco] [--otel] |
| 9 | 10 | # ./start-docker.sh url [name] # the URL again |
| 10 | 11 | # ./start-docker.sh ports [name] # every published endpoint, as KEY=value lines |
| 12 | +# ./start-docker.sh db [name] # the database's JDBC URL and login, as KEY=value | |
| 11 | 13 | # ./start-docker.sh reset [name] # database back to the seed |
| 12 | 14 | # ./start-docker.sh ls |
| 13 | 15 | # ./start-docker.sh down [name|--all] |
| cmd_ports() { | ||
| 269 | 271 | return 0 |
| 270 | 272 | } |
| 271 | 273 | |
| 274 | +# Where DataGrip / DBeaver / psql on the host reach this instance's database, as KEY=value | |
| 275 | +# lines (the review page's "Running Database" row reads them). The login is read off the | |
| 276 | +# container's own environment, so it cannot drift from the compose file. | |
| 277 | +cmd_db() { | |
| 278 | + local name; name="$(resolve "${1:-}")" | |
| 279 | + local p; p="$(published "$name" db 5432)" | |
| 280 | + [ -n "$p" ] || die "$name publishes no database port (started before it was published?)" | |
| 281 | + local env user pass db | |
| 282 | + env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name-db-1")" | |
| 283 | + user="$(printf '%s\n' "$env" | sed -n 's/^POSTGRES_USER=//p')" | |
| 284 | + pass="$(printf '%s\n' "$env" | sed -n 's/^POSTGRES_PASSWORD=//p')" | |
| 285 | + db="$(printf '%s\n' "$env" | sed -n 's/^POSTGRES_DB=//p')" | |
| 286 | + echo "JDBC_URL=jdbc:postgresql://localhost:$p/${db:-${user:-postgres}}" | |
| 287 | + echo "DB_HOST_PORT=$p" | |
| 288 | + echo "DB_CONTAINER_PORT=5432" | |
| 289 | + echo "DB_USER=${user:-postgres}" | |
| 290 | + echo "DB_PASSWORD=$pass" | |
| 291 | +} | |
| 292 | + | |
| 272 | 293 | # Same starting point every time, so repeated deep links cannot pile up duplicate rows or |
| 273 | 294 | # trip a unique constraint. |
| 274 | 295 | # One implementation, reachable two ways. The button on the review page and this command |
| case "${1:-up}" in | ||
| 309 | 330 | up) shift || true; cmd_up "$@" ;; |
| 310 | 331 | url) shift || true; cmd_url "$@" ;; |
| 311 | 332 | ports) shift || true; cmd_ports "$@" ;; |
| 333 | + db) shift || true; cmd_db "$@" ;; | |
| 312 | 334 | reset) shift || true; cmd_reset "$@" ;; |
| 313 | 335 | ls) cmd_ls ;; |
| 314 | 336 | down) shift || true; cmd_down "$@" ;; |
| 315 | - -h|--help|help) sed -n '2,28p' "${BASH_SOURCE[0]}" | sed -e 's/^#//' -e 's/^ //' ;; | |
| 316 | - *) die "unknown command: $1 (up | url | ports | reset | ls | down)" ;; | |
| 337 | + -h|--help|help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed -e 's/^#//' -e 's/^ //' ;; | |
| 338 | + *) die "unknown command: $1 (up | url | ports | db | reset | ls | down)" ;; | |
| 317 | 339 | esac |
| 1 | +#!/bin/bash | |
| 2 | +# Tempo on its own — no Docker, no Grafana: all the sequence diagrams need. | |
| 3 | +# OTLP in on :4318 (where start-backend.sh and the frontend's proxy already send), the | |
| 4 | +# search/trace API on :3200 (TEMPO_URL, read by petclinic-test/src/genseq/tempo-client.ts). | |
| 5 | +# One binary, downloaded once into petclinic-observability/.tools; traces are wiped on | |
| 6 | +# every start. ./start-grafana.sh is the full stack (dashboards, logs, metrics) instead — | |
| 7 | +# the two share :4318, so run one or the other. | |
| 8 | +set -euo pipefail | |
| 9 | +printf '\033]0;Tempo\007' # set terminal/tab title | |
| 10 | +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| 11 | +OBS_DIR="$SCRIPT_DIR/petclinic-observability" | |
| 12 | + | |
| 13 | +source "$SCRIPT_DIR/scripts/preflight.sh" | |
| 14 | +require_ports_free tempo 3200 4318 | |
| 15 | + | |
| 16 | +TEMPO_VERSION="3.1.1" | |
| 17 | +case "$(uname -s)" in | |
| 18 | + Darwin) os=darwin ;; | |
| 19 | + Linux) os=linux ;; | |
| 20 | + MINGW*|MSYS*|CYGWIN*) os=windows ;; | |
| 21 | + *) echo "❌ No Tempo build for $(uname -s)" >&2; exit 1 ;; | |
| 22 | +esac | |
| 23 | +case "$(uname -m)" in | |
| 24 | + arm64|aarch64) arch=arm64 ;; | |
| 25 | + *) arch=amd64 ;; | |
| 26 | +esac | |
| 27 | +TOOL_DIR="$OBS_DIR/.tools/tempo-$TEMPO_VERSION-$os-$arch" | |
| 28 | +TEMPO_BIN="$TOOL_DIR/tempo" | |
| 29 | +[[ $os == windows ]] && TEMPO_BIN="$TEMPO_BIN.exe" | |
| 30 | + | |
| 31 | +if [[ ! -x "$TEMPO_BIN" ]]; then | |
| 32 | + echo "⬇️ Downloading Tempo v$TEMPO_VERSION ($os/$arch, ~65 MB)..." | |
| 33 | + mkdir -p "$TOOL_DIR" | |
| 34 | + curl -fSL --progress-bar \ | |
| 35 | + "https://github.com/grafana/tempo/releases/download/v$TEMPO_VERSION/tempo_${TEMPO_VERSION}_${os}_${arch}.tar.gz" \ | |
| 36 | + | tar xz -C "$TOOL_DIR" | |
| 37 | +fi | |
| 38 | + | |
| 39 | +# tempo-local.yaml points every path at ./.tempo-data, relative to here. | |
| 40 | +cd "$OBS_DIR" | |
| 41 | +rm -rf .tempo-data | |
| 42 | + | |
| 43 | +echo "🚀 Starting Tempo..." | |
| 44 | +# Without the grep, a single binary's background compaction worker logs "nil job received" | |
| 45 | +# errors forever: harmless, and the only thing anyone would read in this window. | |
| 46 | +"$TEMPO_BIN" -config.file tempo-local.yaml \ | |
| 47 | + > >(grep --line-buffered -vE 'caller=backend|unowned file entry') 2>&1 & | |
| 48 | +TEMPO_PID=$! | |
| 49 | +trap 'echo ""; echo "🛑 Stopping Tempo..."; kill $TEMPO_PID 2>/dev/null; wait $TEMPO_PID 2>/dev/null; echo "✅ Stopped"' EXIT | |
| 50 | + | |
| 51 | +for _ in {1..30}; do | |
| 52 | + curl -fsS http://127.0.0.1:3200/ready >/dev/null 2>&1 && break | |
| 53 | + kill -0 $TEMPO_PID 2>/dev/null || { echo "❌ Tempo exited — see the log above" >&2; exit 1; } | |
| 54 | + sleep 1 | |
| 55 | +done | |
| 56 | +echo "✅ Tempo ready: OTLP on :4318, API on http://localhost:3200" | |
| 57 | +echo " the tracing scripts pick it up by themselves; by hand: export TEMPO_URL=http://localhost:3200" | |
| 58 | + | |
| 59 | +# Same trap as with Grafana: the agent is only attached if :4318 answered when the backend booted. | |
| 60 | +backend_pid="$(lsof -nP -iTCP:8080 -sTCP:LISTEN -t 2>/dev/null | head -1 || true)" | |
| 61 | +if [[ -z "$backend_pid" ]]; then | |
| 62 | + echo "ℹ️ Backend not up yet — start it now (./start-backend.sh) so it picks up the OTel agent." | |
| 63 | +elif ps -o command= -p "$backend_pid" 2>/dev/null | grep -q 'opentelemetry-javaagent'; then | |
| 64 | + echo "✅ Backend on :8080 already has the OTel agent attached — traces will be collected." | |
| 65 | +else | |
| 66 | + echo "⚠️ Backend on :8080 is running WITHOUT the OpenTelemetry agent." | |
| 67 | + echo " Restart it (./start-backend.sh) or e2e runs will produce no sequence diagrams." | |
| 68 | +fi | |
| 69 | + | |
| 70 | +echo "📜 Press Ctrl+C to stop." | |
| 71 | +wait $TEMPO_PID |
51 generated files re-recorded in the fix commits (1bcf74d4..088f2f28) — regenerated output, not a fix, so not drawn.
Demo
ELI5: Now every pet visit can say which vet took care of it.
Watch: we pick a vet, leave it empty, then change our mind.
Tests
The Visit should be linked to the vet that attended that consultation. Visit should display its vet everywhere throughout the app.
What it has to do
Out of scope
Searching or filtering visits by vet.
+22
Sequence
The trace of AddVisitApiTest: adds a visit to an existing pet, in Grafana Tempo. Compare it with that test's diagram below. Recorded while the e2e tests ran, 100% sampled: every request is in it.

API
Data
Structure
walked by a testno test walks it
Op counts compare only the 3 tests traced on both sides.
Code City
Complexity
Computed by endpoint-complexity.py with JavaParser and its symbol solver: a syntax tree of the Java sources, every call bound by type.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
if (context == null || !context.elicitEnabled()) {+1if (context == null || !context.elicitEnabled()) {+1if (elicit.action() != ElicitResult.Action.ACCEPT) {+1String address = input == null ? null : input.address();+1if (address == null || address.isBlank()) {+1if (address == null || address.isBlank()) {+1Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.
UX
5 of 19 screens changed · ⚠ +1 gap · +1 design-system component
How the screens were compared
human-review.json, each opened twice, side by side21503874 · branch 788c63dcA screen counts as changed when
The other 14 screens

added: Vetchanged — nothing here to judge

✓ combo — addedchanged — nothing here to judge

changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| ok | 788c63dc | Vetapp-combo[name="vetId"] | design-system component combo | added | — | |
markup and templateThe design system’s combo component ( | ||||||
input[name="date"] — role input[type=text], not covered#description — role input[type=text], not covered
added: Vet

✗ <select> — added

| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| gap | 788c63dc | Vet#vet | select | not the design-system component — a plain <select> where combo belongsthis branch shipped it bare, never migrated | added | — |
why A native | ||||||
input[name="date"] — role input[type=text], not covered#description — role input[type=text], not covered
changed — nothing here to judge

changed — nothing here to judge

changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||

changed — nothing here to judge

changed — nothing here to judge

changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||

changed — nothing here to judge

changed — nothing here to judge

changed — nothing here to judge
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below. | ||||||
Logging
Found by syntax-aware search over Java sources: ast-grep (open source, built on tree-sitter) parses each file, and this skill's own rules (logextract.py) first find which fields are loggers, then every log-level call made on one.
+201log.info("Booked visit {} for pet {}", visit.getId(), petId);+203log.debug("Attending vet: {}", vet.getLastName());CODEOWNERS
Cost
| step | time | cost | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
ImplementationClaude Code 82329d0a | 11 min | $4.78Opus 5.5 (high) | ||||||||||||||||||||||||||||||
ReviewClaude Code 82329d0a · the reviewers and their brief | 1 min | $1.53Sonnet 5.5 (medium) · Opus 5.5 (high) | ||||||||||||||||||||||||||||||
Auto-fixesClaude Code 82329d0a · deciding and fixing | 12 min | $2.61Opus 5.5 (high) | ||||||||||||||||||||||||||||||
| plus 59 min of later refreshes, no model | 3 min | $0.41Sonnet 5.5 | ||||||||||||||||||||||||||||||
| claude -p on Sonnet 5.5 · requirements↔tests mapping | 1 min | $0.12 | ||||||||||||||||||||||||||||||
| claude -p on Sonnet 5.5 · film script (rerun-film.py) | 2 min | $0.27 | ||||||||||||||||||||||||||||||
| claude -p on Sonnet 5.5 · verdict (write-content.py) | 5 s | $0.02 | ||||||||||||||||||||||||||||||
| ||||||||||||||||||||||||||||||||
| VoicesFish Audio, 2 voices | < $0.01s2.1-pro-free | |||||||||||||||||||||||||||||||
| Total | 27 min | $9.33 | ||||||||||||||||||||||||||||||
Building human-review itself — the tool, not this change
227 sessions, 27 Aug 2026 → 20 Oct 2026