Commit 3de1ec4d23

3de1ec4d23d340917c95cdcedef2abd8c84f2d96

parent: b96f8005cf

Unsigned

cmc <hello@cleberg.net> · 2026-09-19 04:28 UTC

Live suite steps for the 1.2.0 flows (!108)

Five live tests for !99–!104, all green on iPhone 17 Pro against gitbay.org, with the full 19-test suite green. Snippet file actions move to a header menu (a swipe on the code row scrolled the code); identifiers on the label create row; the account flow scrolls to the PGP key.

Layout: unified · split

gitbay/Views/Issues/LabelListView.swift +2
@@ -29,6 +29,7 @@ struct LabelListView: View {
2929 TextField("Label", text: $newLabel)
3030 .autocorrectionDisabled()
3131 .textInputAutocapitalization(.never)
32 .accessibilityIdentifier("label-new-name")
3233 Button {
3334 let name = newLabel.trimmingCharacters(in: .whitespaces)
3435 newLabel = ""
@@ -39,6 +40,7 @@ struct LabelListView: View {
3940 .disabled(
4041 newLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working
4142 )
43 .accessibilityIdentifier("label-create-submit")
4244 }
4345 }
4446
gitbay/Views/Snippets/SnippetView.swift +12 −7
@@ -175,13 +175,6 @@ struct SnippetView: View {
175175 .padding(.vertical, 4)
176176 }
177177 .listRowInsets(EdgeInsets(top: 4, leading: 12, bottom: 4, trailing: 12))
178 .swipeActions {
179 if isOwner {
180 Button("Remove", role: .destructive) { removingFile = file }
181 Button("Edit") { editingFile = file }
182 .tint(.gbAccent)
183 }
184 }
185178 } header: {
186179 HStack {
187180 Text(file.name)
@@ -189,6 +182,18 @@ struct SnippetView: View {
189182 .textCase(nil)
190183 Spacer()
191184 Text(Int64(file.size).formatted(.byteCount(style: .file)))
185 // A swipe on the code row scrolls the code, so the
186 // file's actions live on its header.
187 if isOwner {
188 Menu {
189 Button("Edit") { editingFile = file }
190 Button("Remove", role: .destructive) { removingFile = file }
191 } label: {
192 Image(systemName: "ellipsis.circle")
193 }
194 .textCase(nil)
195 .accessibilityIdentifier("snippet-file-menu-\(file.name)")
196 }
192197 }
193198 }
194199 }
gitbayUITests/LiveSmokeUITests.swift +415 −3
@@ -52,12 +52,107 @@ final class LiveSmokeUITests: XCTestCase {
5252 if name.contains("testBlameAndEditFlows") {
5353 try requireFixtureRepo()
5454 }
55 if name.contains("testSnippetFlows") {
56 deleteSmokeSnippets()
57 }
58 if name.contains("testOrgLabelAndMilestoneFlows") {
59 ensureOrgFixtures()
60 }
61 if name.contains("testCloseInFavourFlow") {
62 try requireFixtureBranch()
63 closeSmokeMergeRequests()
64 try openSupersedeFixtures()
65 }
5566 }
5667
5768 override func tearDownWithError() throws {
5869 if name.contains("testRepoManagementFlows") {
5970 deleteScratchRepo()
6071 }
72 if name.contains("testSnippetFlows") {
73 deleteSmokeSnippets()
74 }
75 if name.contains("testCloseInFavourFlow") {
76 closeSmokeMergeRequests()
77 }
78 }
79
80 // MARK: - Fixtures for the v1.2.0 flows
81
82 static let smokeSnippetDescription = "UI smoke snippet"
83
84 /// Remove every snippet the flow made, before and after, so a run
85 /// that died mid-way cannot strand one or hit the per-account cap.
86 private func deleteSmokeSnippets() {
87 guard let data = readCommand(["snippet", "list"]),
88 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
89 let rows = envelope["data"] as? [[String: Any]] else { return }
90 for row in rows where row["description"] as? String == Self.smokeSnippetDescription {
91 if let id = row["id"] as? String {
92 _ = runCommand(["snippet", "delete", id])
93 }
94 }
95 }
96
97 static let smokeOrg = "krz"
98 static let smokeOrgLabel = "ui-smoke-label"
99 static let smokeOrgMilestone = "ui-smoke milestone"
100
101 /// The org milestone is a fixture, reused and left closed: the
102 /// server has no milestone delete, so creating one per run would
103 /// pile them up. The label is removed by the flow itself; a stale
104 /// one is removed here first.
105 private func ensureOrgFixtures() {
106 _ = runCommand(["org", "label", "remove", Self.smokeOrg, Self.smokeOrgLabel])
107 guard let data = readCommand(["org", "milestone", "list", Self.smokeOrg, "--state", "all"]),
108 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
109 else { return }
110 let rows = envelope["data"] as? [[String: Any]] ?? []
111 if let row = rows.first(where: { $0["title"] as? String == Self.smokeOrgMilestone }) {
112 if row["state"] as? String == "open" {
113 _ = runCommand(["org", "milestone", "close", Self.smokeOrg, Self.smokeOrgMilestone])
114 }
115 } else {
116 _ = runCommand(["org", "milestone", "create", Self.smokeOrg, Self.smokeOrgMilestone])
117 _ = runCommand(["org", "milestone", "close", Self.smokeOrg, Self.smokeOrgMilestone])
118 }
119 }
120
121 static let supersededTitle = "UI smoke: superseded"
122 static let successorTitle = "UI smoke: successor"
123
124 /// Close in favour needs two open merge requests. The fixture branch
125 /// gives one (ui-smoke -> main); main has commits ui-smoke lacks, so
126 /// the reverse (main -> ui-smoke) is the other. Both are the API's
127 /// to open; the flow closes the first from the app and tearDown
128 /// closes whatever is left.
129 private func openSupersedeFixtures() throws {
130 _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main",
131 "--title", Self.supersededTitle])
132 _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "main", "--target", "ui-smoke",
133 "--title", Self.successorTitle])
134 let open = openSmokeMergeRequests().map { $0.title }
135 try XCTSkipUnless(open.contains(Self.supersededTitle) && open.contains(Self.successorTitle), """
136 could not open both fixture merge requests on krz/gitbay-ios \
137 (have \(open)); the ui-smoke branch may be level with main.
138 """)
139 }
140
141 private func openSmokeMergeRequests() -> [(number: Int, title: String)] {
142 guard let data = readCommand(["mr", "list", Self.fixtureRepo, "--state", "open"]),
143 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
144 let rows = envelope["data"] as? [[String: Any]] else { return [] }
145 return rows.compactMap { row in
146 guard let number = row["number"] as? Int, let title = row["title"] as? String,
147 title.hasPrefix("UI smoke:") else { return nil }
148 return (number, title)
149 }
150 }
151
152 private func closeSmokeMergeRequests() {
153 for mr in openSmokeMergeRequests() {
154 _ = runCommand(["mr", "close", Self.fixtureRepo, String(mr.number)])
155 }
61156 }
62157
63158 /// `repo delete` over the JSON API — the app has no delete screen
@@ -821,14 +916,14 @@ extension LiveSmokeUITests {
821916 // Dashboard toolbar -> account screen.
822917 openAccountScreen()
823918
824 // Real keys render: SSH fingerprints and the PGP key's UID email.
919 // Real keys render: SSH fingerprints first.
825920 XCTAssertTrue(app.staticTexts
826921 .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
827922 .waitForExistence(timeout: 15), "SSH keys missing")
828 XCTAssertTrue(app.staticTexts["hello@cleberg.net"].firstMatch
829 .waitForExistence(timeout: 10), "PGP key UID missing")
830923
831924 // Pasting garbage as an SSH key surfaces the server's validation.
925 // Done while the SSH section is still on screen: labelled keys
926 // are two lines each, so what follows sits below the fold.
832927 app.descendants(matching: .any).matching(identifier: "add-ssh-key")
833928 .firstMatch.tap()
834929 let paste = app.descendants(matching: .any)
@@ -842,6 +937,11 @@ extension LiveSmokeUITests {
842937 .waitForExistence(timeout: 15), "invalid-key refusal not surfaced")
843938 app.buttons["Cancel"].firstMatch.tap()
844939
940 // The PGP key's UID email, below the keys on a List that does not
941 // build rows it has not shown.
942 XCTAssertTrue(scrollTo(app.staticTexts["hello@cleberg.net"].firstMatch),
943 "PGP key UID missing")
944
845945 // Activity mail flips off and back on; the value comes back from
846946 // the server each time.
847947 let mail = app.switches["Activity by mail"].firstMatch
@@ -1373,3 +1473,315 @@ extension LiveSmokeUITests {
13731473 snap("profile")
13741474 }
13751475}
1476
1477extension LiveSmokeUITests {
1478
1479 /// Snippets, a full life on the caller's own account: create, add a
1480 /// file, replace it, remove it, change visibility, delete. setUp and
1481 /// tearDown remove anything left by this description.
1482 func testSnippetFlows() throws {
1483 selectTab("My Profile")
1484 app.descendants(matching: .any).matching(identifier: "account-menu").firstMatch.tap()
1485 let snippets = app.buttons["Snippets"].firstMatch
1486 XCTAssertTrue(snippets.waitForExistence(timeout: 5), "Snippets missing from the account menu")
1487 snippets.tap()
1488
1489 // --- create ---
1490 let create = app.descendants(matching: .any)
1491 .matching(identifier: "snippet-create-button").firstMatch
1492 XCTAssertTrue(create.waitForExistence(timeout: 15), "snippet list did not open")
1493 create.tap()
1494 let nameField = app.descendants(matching: .any)
1495 .matching(identifier: "snippet-file-name").firstMatch
1496 XCTAssertTrue(nameField.waitForExistence(timeout: 5), "create sheet did not open")
1497 focusAndType(nameField, "ui-smoke.txt")
1498 focusAndType(app.descendants(matching: .any)
1499 .matching(identifier: "snippet-description").firstMatch, Self.smokeSnippetDescription)
1500 let content = app.descendants(matching: .any)
1501 .matching(identifier: "snippet-content").firstMatch
1502 focusAndType(content, "hello from the live suite")
1503 app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1504 XCTAssertTrue(waitForDisappearance(nameField, timeout: 20), "create did not dismiss")
1505
1506 let row = app.staticTexts[Self.smokeSnippetDescription].firstMatch
1507 XCTAssertTrue(row.waitForExistence(timeout: 15), "created snippet not listed")
1508 row.tap()
1509
1510 // --- detail: the file renders, unlisted by default ---
1511 XCTAssertTrue(app.staticTexts["ui-smoke.txt"].firstMatch.waitForExistence(timeout: 15),
1512 "snippet file missing")
1513 XCTAssertTrue(app.staticTexts["unlisted"].firstMatch.exists, "default visibility not unlisted")
1514
1515 // --- add a second file ---
1516 let actions = app.descendants(matching: .any)
1517 .matching(identifier: "snippet-actions-menu").firstMatch
1518 XCTAssertTrue(actions.waitForExistence(timeout: 10), "owner actions missing")
1519 actions.tap()
1520 let addFile = app.buttons["Add file"].firstMatch
1521 XCTAssertTrue(addFile.waitForExistence(timeout: 5))
1522 addFile.tap()
1523 let secondName = app.descendants(matching: .any)
1524 .matching(identifier: "snippet-file-name").firstMatch
1525 XCTAssertTrue(secondName.waitForExistence(timeout: 5), "add-file sheet did not open")
1526 focusAndType(secondName, "second.txt")
1527 focusAndType(app.descendants(matching: .any)
1528 .matching(identifier: "snippet-content").firstMatch, "two")
1529 app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1530 XCTAssertTrue(waitForDisappearance(secondName, timeout: 20), "add file did not dismiss")
1531 let second = app.staticTexts["second.txt"].firstMatch
1532 XCTAssertTrue(second.waitForExistence(timeout: 15), "added file missing")
1533
1534 // --- replace its content from the file menu ---
1535 let fileMenu = app.descendants(matching: .any)
1536 .matching(identifier: "snippet-file-menu-second.txt").firstMatch
1537 XCTAssertTrue(scrollTo(fileMenu), "file menu not reachable")
1538 fileMenu.tap()
1539 let edit = app.buttons["Edit"].firstMatch
1540 XCTAssertTrue(edit.waitForExistence(timeout: 5))
1541 edit.tap()
1542 let editor = app.descendants(matching: .any)
1543 .matching(identifier: "snippet-content").firstMatch
1544 XCTAssertTrue(editor.waitForExistence(timeout: 5), "edit sheet did not open")
1545 XCTAssertTrue((editor.value as? String)?.contains("two") == true, "edit sheet did not prefill")
1546 focusAndType(editor, " and three")
1547 app.descendants(matching: .any).matching(identifier: "snippet-submit").firstMatch.tap()
1548 XCTAssertTrue(waitForDisappearance(editor, timeout: 20), "file save did not dismiss")
1549 XCTAssertTrue(app.staticTexts
1550 .containing(NSPredicate(format: "label CONTAINS 'and three'")).firstMatch
1551 .waitForExistence(timeout: 15), "replaced content not rendered")
1552
1553 // --- remove it, through its confirmation ---
1554 XCTAssertTrue(scrollTo(fileMenu), "file menu not reachable after edit")
1555 fileMenu.tap()
1556 app.buttons["Remove"].firstMatch.tap()
1557 let confirmRemove = app.buttons["Remove"].firstMatch
1558 XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "remove confirmation missing")
1559 confirmRemove.tap()
1560 XCTAssertTrue(waitForDisappearance(second, timeout: 15), "file not removed")
1561
1562 // --- visibility to public ---
1563 actions.tap()
1564 let settings = app.buttons["Description & visibility"].firstMatch
1565 XCTAssertTrue(settings.waitForExistence(timeout: 5))
1566 settings.tap()
1567 let visibility = app.segmentedControls.firstMatch
1568 XCTAssertTrue(visibility.waitForExistence(timeout: 5), "settings sheet did not open")
1569 visibility.buttons["public"].tap()
1570 app.buttons["Save"].firstMatch.tap()
1571 XCTAssertTrue(app.staticTexts["public"].firstMatch.waitForExistence(timeout: 15),
1572 "visibility change not shown")
1573
1574 // --- delete, through its confirmation; the list no longer has it ---
1575 actions.tap()
1576 app.buttons["Delete snippet"].firstMatch.tap()
1577 let confirmDelete = app.buttons["Delete"].firstMatch
1578 XCTAssertTrue(confirmDelete.waitForExistence(timeout: 5), "delete confirmation missing")
1579 confirmDelete.tap()
1580 XCTAssertTrue(create.waitForExistence(timeout: 15), "did not return to the snippet list")
1581 XCTAssertTrue(waitForDisappearance(row, timeout: 15), "deleted snippet still listed")
1582 }
1583
1584 /// An SSH key's label round-trips: set from the row's swipe action,
1585 /// then restored to what it was (or cleared).
1586 func testSSHKeyLabelFlow() throws {
1587 openAccountScreen()
1588 let fingerprint = app.staticTexts
1589 .containing(NSPredicate(format: "label BEGINSWITH 'SHA256:'")).firstMatch
1590 XCTAssertTrue(fingerprint.waitForExistence(timeout: 15), "SSH keys missing")
1591 let fp = fingerprint.label
1592
1593 // What the key is called now, so it can be put back.
1594 var original = ""
1595 if let data = readCommand(["keys", "list"]),
1596 let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1597 let rows = envelope["data"] as? [[String: Any]],
1598 let row = rows.first(where: { $0["fingerprint"] as? String == fp }) {
1599 original = row["label"] as? String ?? ""
1600 }
1601
1602 let relabel: (String) -> Void = { text in
1603 fingerprint.swipeLeft()
1604 let label = self.app.buttons["Label"].firstMatch
1605 XCTAssertTrue(label.waitForExistence(timeout: 5), "Label swipe action missing")
1606 label.tap()
1607 let field = self.app.alerts.textFields.firstMatch
1608 XCTAssertTrue(field.waitForExistence(timeout: 5), "label alert missing")
1609 self.clearAndType(field, text)
1610 self.app.alerts.buttons["Save"].firstMatch.tap()
1611 }
1612
1613 relabel("ui-smoke")
1614 XCTAssertTrue(app.staticTexts["ui-smoke"].firstMatch.waitForExistence(timeout: 15),
1615 "label not shown after save")
1616
1617 relabel(original)
1618 XCTAssertTrue(waitForDisappearance(app.staticTexts["ui-smoke"].firstMatch, timeout: 15),
1619 "label not restored")
1620 if !original.isEmpty {
1621 XCTAssertTrue(app.staticTexts[original].firstMatch.waitForExistence(timeout: 10),
1622 "original label not back")
1623 }
1624 }
1625
1626 /// Org labels and milestones from the org screen: a label is created
1627 /// and removed; the fixture milestone is reopened and closed again,
1628 /// and creating it a second time surfaces the server's refusal.
1629 func testOrgLabelAndMilestoneFlows() throws {
1630 openAccountScreen()
1631 let orgRow = app.staticTexts[Self.smokeOrg].firstMatch
1632 XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing")
1633 orgRow.tap()
1634 XCTAssertTrue(app.staticTexts["cmc"].firstMatch.waitForExistence(timeout: 15),
1635 "org members missing")
1636
1637 // --- labels ---
1638 let labels = app.staticTexts["Labels"].firstMatch
1639 XCTAssertTrue(scrollTo(labels), "Labels link not reachable")
1640 labels.tap()
1641 let newName = app.descendants(matching: .any)
1642 .matching(identifier: "label-new-name").firstMatch
1643 XCTAssertTrue(newName.waitForExistence(timeout: 15), "org labels did not open")
1644 focusAndType(newName, Self.smokeOrgLabel)
1645 app.descendants(matching: .any).matching(identifier: "label-create-submit").firstMatch.tap()
1646 let chip = app.staticTexts[Self.smokeOrgLabel].firstMatch
1647 XCTAssertTrue(chip.waitForExistence(timeout: 15), "org label not listed after create")
1648
1649 // The org's own screen manages its rows: the menu is there, not
1650 // the read-only mark.
1651 let cell = app.cells.containing(.staticText, identifier: Self.smokeOrgLabel).firstMatch
1652 let menu = cell.buttons.firstMatch
1653 XCTAssertTrue(menu.waitForExistence(timeout: 5), "org label has no menu on the org screen")
1654 menu.tap()
1655 let remove = app.buttons["Remove"].firstMatch
1656 XCTAssertTrue(remove.waitForExistence(timeout: 5))
1657 remove.tap()
1658 let confirmRemove = app.buttons["Remove"].firstMatch
1659 XCTAssertTrue(confirmRemove.waitForExistence(timeout: 5), "remove confirmation missing")
1660 confirmRemove.tap()
1661 XCTAssertTrue(waitForDisappearance(chip, timeout: 15), "org label not removed")
1662 back()
1663
1664 // --- milestones ---
1665 let milestones = app.staticTexts["Milestones"].firstMatch
1666 XCTAssertTrue(scrollTo(milestones), "Milestones link not reachable")
1667 milestones.tap()
1668 let picker = app.segmentedControls.firstMatch
1669 XCTAssertTrue(picker.waitForExistence(timeout: 15), "org milestones did not open")
1670 picker.buttons["Closed"].tap()
1671 let fixture = app.staticTexts[Self.smokeOrgMilestone].firstMatch
1672 XCTAssertTrue(fixture.waitForExistence(timeout: 15), "fixture milestone not among closed")
1673 fixture.swipeLeft()
1674 let reopen = app.buttons["Reopen"].firstMatch
1675 XCTAssertTrue(reopen.waitForExistence(timeout: 5), "reopen action missing on the org screen")
1676 reopen.tap()
1677 XCTAssertTrue(waitForDisappearance(fixture, timeout: 15), "reopened milestone still among closed")
1678 picker.buttons["Open"].tap()
1679 XCTAssertTrue(fixture.waitForExistence(timeout: 15), "reopened milestone not among open")
1680
1681 // Creating it again is refused with the server's sentence.
1682 app.descendants(matching: .any).matching(identifier: "milestone-create-button").firstMatch.tap()
1683 let title = app.descendants(matching: .any)
1684 .matching(identifier: "milestone-title").firstMatch
1685 XCTAssertTrue(title.waitForExistence(timeout: 5), "create sheet did not open")
1686 focusAndType(title, Self.smokeOrgMilestone)
1687 app.descendants(matching: .any).matching(identifier: "milestone-submit").firstMatch.tap()
1688 XCTAssertTrue(app.staticTexts
1689 .containing(NSPredicate(format: "label CONTAINS[c] 'already'")).firstMatch
1690 .waitForExistence(timeout: 15), "duplicate milestone refusal not surfaced")
1691 app.buttons["Cancel"].firstMatch.tap()
1692
1693 // Back to closed, where the fixture rests.
1694 XCTAssertTrue(fixture.waitForExistence(timeout: 10))
1695 fixture.swipeLeft()
1696 let close = app.buttons["Close"].firstMatch
1697 XCTAssertTrue(close.waitForExistence(timeout: 5), "close action missing on the org screen")
1698 close.tap()
1699 XCTAssertTrue(waitForDisappearance(fixture, timeout: 15), "closed milestone still among open")
1700 }
1701
1702 /// Build list filters on a repo with builds: a status narrows the
1703 /// list, a job narrows it further, and an impossible pair shows the
1704 /// filtered empty state. Clearing brings the list back.
1705 func testBuildFilterFlow() throws {
1706 openRepo("krz/gitbay")
1707 app.staticTexts["Builds"].firstMatch.tap()
1708 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed")
1709
1710 let filter = app.descendants(matching: .any)
1711 .matching(identifier: "build-filter-button").firstMatch
1712 XCTAssertTrue(filter.waitForExistence(timeout: 10), "filter button missing")
1713
1714 let choose: (String, String) -> Void = { menuId, option in
1715 let menu = self.app.descendants(matching: .any).matching(identifier: menuId).firstMatch
1716 XCTAssertTrue(menu.waitForExistence(timeout: 5), "\(menuId) missing")
1717 menu.tap()
1718 let choice = self.app.buttons[option].firstMatch
1719 XCTAssertTrue(choice.waitForExistence(timeout: 5), "\(option) not offered")
1720 choice.tap()
1721 }
1722
1723 // status = success: rows remain and every row says success.
1724 filter.tap()
1725 choose("build-filter-status-menu", "success")
1726 app.buttons["Done"].firstMatch.tap()
1727 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no successful builds")
1728 XCTAssertFalse(app.staticTexts["failure"].firstMatch.exists, "a failed build survived the filter")
1729
1730 // job = test as well: the rows name the job.
1731 filter.tap()
1732 choose("build-filter-job-menu", "test")
1733 app.buttons["Done"].firstMatch.tap()
1734 XCTAssertTrue(app.staticTexts
1735 .containing(NSPredicate(format: "label CONTAINS ' test'")).firstMatch
1736 .waitForExistence(timeout: 20), "job filter did not narrow to test")
1737
1738 // status = pending on a quiet repo: the filtered empty state.
1739 filter.tap()
1740 choose("build-filter-status-menu", "pending")
1741 app.buttons["Done"].firstMatch.tap()
1742 XCTAssertTrue(app.staticTexts["No builds match these filters."].firstMatch
1743 .waitForExistence(timeout: 20), "filtered empty state missing")
1744
1745 // Clear: the list is back.
1746 filter.tap()
1747 app.buttons["Clear filters"].firstMatch.tap()
1748 app.buttons["Done"].firstMatch.tap()
1749 XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "list did not come back")
1750 }
1751
1752 /// Close in favour of another: the first fixture merge request is
1753 /// closed from the app naming the second; the page then says so and
1754 /// links to it.
1755 func testCloseInFavourFlow() throws {
1756 openRepo(Self.fixtureRepo)
1757 app.staticTexts["Merge Requests"].firstMatch.tap()
1758 let superseded = app.staticTexts[Self.supersededTitle].firstMatch
1759 XCTAssertTrue(superseded.waitForExistence(timeout: 15), "fixture MR not listed")
1760 superseded.tap()
1761
1762 app.descendants(matching: .any).matching(identifier: "mr-actions-menu").firstMatch.tap()
1763 let close = app.buttons["Close"].firstMatch
1764 XCTAssertTrue(close.waitForExistence(timeout: 5))
1765 close.tap()
1766 let inFavour = app.buttons["Close in favour of another…"].firstMatch
1767 XCTAssertTrue(inFavour.waitForExistence(timeout: 5), "close dialog has no in-favour choice")
1768 inFavour.tap()
1769
1770 let successor = app.staticTexts[Self.successorTitle].firstMatch
1771 XCTAssertTrue(successor.waitForExistence(timeout: 15), "successor not offered")
1772 successor.tap()
1773 let confirm = app.buttons["Close"].firstMatch
1774 XCTAssertTrue(confirm.waitForExistence(timeout: 5), "in-favour confirmation missing")
1775 confirm.tap()
1776
1777 XCTAssertTrue(app.staticTexts
1778 .containing(NSPredicate(format: "label BEGINSWITH 'closed in favour of !'")).firstMatch
1779 .waitForExistence(timeout: 20), "resolution does not name the successor")
1780 let link = app.staticTexts
1781 .containing(NSPredicate(format: "label BEGINSWITH 'Superseded by !'")).firstMatch
1782 XCTAssertTrue(scrollTo(link), "superseded-by link missing")
1783 link.tap()
1784 XCTAssertTrue(app.staticTexts[Self.successorTitle].firstMatch.waitForExistence(timeout: 20),
1785 "link did not open the successor")
1786 }
1787}