Give each view its own surface and the report one device picker (refs #439) by tylervick · Pull Request #484 · XCTestHTMLReport/XCTestHTMLReport · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,944 changes: 1,219 additions & 725 deletions Sources/XCTestHTMLReportCore/Classes/HTMLTemplates.swift

Large diffs are not rendered by default.

39 changes: 36 additions & 3 deletions Sources/XCTestHTMLReportCore/Classes/Models/Run.swift
64 changes: 18 additions & 46 deletions Sources/XCTestHTMLReportCore/Classes/Models/RunDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,39 +8,26 @@

import Foundation

// TODO: Check usages, Status already contains cssClass property
private extension Status {
/// e.g. <span class="icon left failure"></span>
var iconCssClass: String {
switch self {
case .failure:
return "failure"
case .success:
return "success"
case .skipped:
return "skip"
case .mixed:
return "mixed"
default:
return ""
}
}

/// Only show icon for failures
var iconHTML: String {
guard self == .failure ||
self == .success
else {
return ""
}
return "<span class=\"device-result icon left \(iconCssClass)\"></span>"
}
}

struct RunDestination: HTML {
/// Where one run executed.
///
/// This stopped rendering itself in A3a (#439). It used to own the device
/// sidebar's card — name, OS, model and the report's own element handle,
/// stacked in a `<ul>` with a click handler on it — and the sidebar is gone.
/// What replaced it, the header device picker, needs the run's *tally* beside
/// each destination, which this type has no way to reach, so the picker is
/// built by `RunSummary` (see `RunSummary+HTML.deviceOption`) from a view
/// model that holds both. This is now a plain value the rest of the report
/// reads fields out of.
///
/// `status` went with the markup. It was never derived — a standing `TODO`
/// left it hardcoded to `.unknown`, and the sidebar's icon rule drew nothing
/// at all for that case, so every card in every report ever rendered showed a
/// blank status cell. The picker states the real outcome, taken from
/// `Run.status`, which is the same value the summary band's bars are built
/// from.
struct RunDestination {
let name: String
let targetDevice: TargetDevice
let status: Status

init(destination: ParsedDestination, identifierPath: IdentifierPath) {
Logger.substep("Parsing run destination")
Expand All @@ -49,20 +36,5 @@ struct RunDestination: HTML {
destination: destination,
identifierPath: identifierPath.appending("device")
)
status = .unknown // TODO: (Pierre Felgines) 04/10/2019 Find the correct value
}

// PRAGMA MARK: - HTML

var htmlTemplate = HTMLTemplates.device

var htmlPlaceholderValues: [String: String] {
[
"DEVICE_RESULT": status.iconHTML,
"DEVICE_NAME": name.stringByEscapingXMLChars,
"DEVICE_IDENTIFIER": targetDevice.uniqueIdentifier,
"DEVICE_MODEL": targetDevice.model.stringByEscapingXMLChars,
"DEVICE_OS": targetDevice.osVersion.stringByEscapingXMLChars,
]
}
}
129 changes: 112 additions & 17 deletions Sources/XCTestHTMLReportCore/Classes/Models/RunSummary+HTML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
// RunSummary+HTML.swift
// XCTestHTMLReport
//
// Rendering for the summary header (#439, A1). Split from `RunSummary.swift`
// so the derivation — which is the part with rules about what may be read —
// is not read alongside the string substitution.
// Rendering for the summary header (#439, A1) and, since A3a, for the device
// picker the header's device bars became. Split from `RunSummary.swift` so the
// derivation — which is the part with rules about what may be read — is not
// read alongside the string substitution.
//
// Two renderings of one view model, deliberately: the band and the picker
// state the same runs in the same order, from the same tallies, so they cannot
// disagree. They are separate strings because they land in different parts of
// the page — see `pickerHTML`.
//

import Foundation
Expand All @@ -23,11 +29,42 @@ extension RunSummary: HTML {
"DEVICES_LABEL": devices.count == 1 ? "1 device" : "\(devices.count) devices",
"DONUT": donutHTML,
"LEGEND": tally.buckets.map(Self.legendRow).joined(),
"DEVICE_BARS": devices.map(Self.deviceRow).joined(),
"FAILURE_DIGEST": failureDigestHTML,
]
}

/// The device picker (#439, A3a), rendered separately from the band.
///
/// Separately, because it belongs to a different part of the page: the
/// band stands down for the Logs view and the picker must not, or reading
/// a log would mean being unable to choose whose log it is. It is built
/// here rather than in `RunDestination` because this is the type that
/// already holds every run's tally, and the bars are half of what an
/// option says.
var pickerHTML: String {
// A one-run report needs no run number and a several-run report can
// need it badly — see `DeviceRow.ordinal`. Decided once, here, so every
// option and the collapsed summary agree about whether they carry one.
let numbered = devices.count > 1
// Options first, current second — see `deviceOption` for the rule. The
// other order let a destination named `[[DEVICE_OPTIONS]]` pull the
// whole panel of buttons into the collapsed summary's one-line span.
// This way round the only exposure left is a destination named
// `[[CURRENT_DEVICE]]`, which gets a copy of the current label inside
// an option: text where the other was controls. `PlaceholderOrderTests`
// pins both this order and that boundary.
return HTMLTemplates.devicePicker
.replacingOccurrences(
of: "[[DEVICE_OPTIONS]]",
with: devices.map { Self.deviceOption($0, numbered: numbered) }.joined()
)
.replacingOccurrences(
of: "[[CURRENT_DEVICE]]",
with: devices.first.map { Self.destinationLabel($0, numbered: numbered) }
?? "No device"
)
}

/// The class the shared icon rules paint the run-wide glyph from. Those
/// rules are named for the outcome, not for the `Status` case (`.skipped`
/// draws `.skip`), so the mapping is spelled out rather than reusing
Expand All @@ -40,8 +77,8 @@ extension RunSummary: HTML {
}
}

/// "12 passed, 6 failed, 1 skipped" — the device bars' visible caption,
/// and the only reading of the bar that assistive technology gets, since
/// "12 passed, 6 failed, 1 skipped" — the caption under a picker option's
/// bar, and the only reading of that bar assistive technology gets, since
/// the bar itself is `aria-hidden`.
private static func spokenTally(_ tally: RunSummary.Tally) -> String {
guard tally.total > 0 else {
Expand All @@ -59,22 +96,80 @@ extension RunSummary: HTML {
.replacingOccurrences(of: "[[COUNT]]", with: String(bucket.count))
}

private static func deviceRow(_ device: DeviceRow) -> String {
// The OS version is a secondary span rather than being folded into the
// name, because it is the field that tells two otherwise identical
// destinations apart in a multi-runtime test plan.
let name = device.name.stringByEscapingXMLChars
let label = device.osVersion.isEmpty
? name
: name + " <span class=\"device-row-os\">"
+ device.osVersion.stringByEscapingXMLChars + "</span>"
return HTMLTemplates.summaryDeviceRow
.replacingOccurrences(of: "[[DEVICE_LABEL]]", with: label)
/// One destination's label: the name, then the OS version in a secondary
/// span rather than folded into it, because the version is the field that
/// tells two otherwise identical destinations apart in a multi-runtime
/// test plan.
///
/// Rendered markup, not leaf text — both leaves inside it are escaped
/// here.
private static func destinationLabel(_ device: DeviceRow, numbered: Bool) -> String {
var label = device.name.stringByEscapingXMLChars
if !device.osVersion.isEmpty {
label += " <span class=\"device-row-os\">"
+ device.osVersion.stringByEscapingXMLChars + "</span>"
}
guard numbered else {
return label
}
// Inside the label rather than beside it, so the collapsed summary —
// which the script fills from this element's text — carries the run
// number too. A picker that can only tell two identical destinations
// apart while it is open is not telling the reader which one they are
// reading.
return label + " <span class=\"device-row-run\">Run \(device.ordinal)</span>"
}

/// One option in the picker: the run's outcome glyph, its destination, the
/// proportional bar and the same split in words, then the model.
///
/// The bar is `aria-hidden` for the reason the ring is — the tally beside
/// it is the accessible reading of the identical fact.
///
/// What the device sidebar showed and this does not is its `Identifier:`
/// line. Since #430 that line has carried the report's own element handle
/// — a 32-character digest of the run's path through the report — rather
/// than the destination's identifier, so it named nothing a reader could
/// use or match against anything outside the page. The handle is still
/// there, in `data-device` and in the ids it addresses, where it is
/// machinery rather than content.
private static func deviceOption(_ device: DeviceRow, numbered: Bool) -> String {
// Order is a rule here, not a style: a chain of replacements fills
// placeholders that earlier replacements *inserted* as readily as the
// ones the template author wrote, so a value ends up protected by
// going in after every placeholder it could contain the literal text
// of — never before. This ran the other way round until the A3a
// review: the identifier was substituted last "so a model or a device
// name containing this placeholder could not be filled by it", which
// is exactly backwards, since by then the name is already in the
// string and its `[[DEVICE_IDENTIFIER]]` is a placeholder like any
// other. Nothing unsafe reached the page — the digest is opaque hex
// and every leaf is escaped before it is inserted — but a destination
// named after the placeholder rendered a digest in its own name.
//
// The identifier goes first because it is the one value here that is
// machine-derived: a 32-character digest that cannot contain a
// placeholder, so nothing it inserts can be filled by what follows.
// Of the two test-plan strings, the label goes ahead of the model
// rather than the other way round: neither can be protected from the
// other, and the worse of the two exposures is the one that inserts
// *markup* into leaf text, which is the label.
HTMLTemplates.deviceOption
.replacingOccurrences(of: "[[DEVICE_IDENTIFIER]]", with: device.identifier)
.replacingOccurrences(
of: "[[DEVICE_STATUS_CLASS]]", with: iconClass(for: device.status)
)
.replacingOccurrences(of: "[[SEGMENTS]]", with: barSegments(device.tally))
.replacingOccurrences(
of: "[[DEVICE_TALLY]]",
with: spokenTally(device.tally).stringByEscapingXMLChars
)
.replacingOccurrences(
of: "[[DEVICE_LABEL]]", with: destinationLabel(device, numbered: numbered)
)
.replacingOccurrences(
of: "[[DEVICE_MODEL]]", with: device.model.stringByEscapingXMLChars
)
}

/// The bar's rects, in a 0–100 user-space viewBox so a segment's width is
Expand Down
44 changes: 41 additions & 3 deletions Sources/XCTestHTMLReportCore/Classes/Models/RunSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ struct RunSummary {

init(runs: [Run]) {
tally = runs.map(\.tally).reduce(.empty, +)
devices = runs.map(DeviceRow.init)
devices = runs.enumerated().map { DeviceRow(run: $1, ordinal: $0 + 1) }
failures = runs.flatMap(\.failureRows)
duration = runs.reduce(0) { $0 + $1.duration }
if runs.contains(where: { $0.status == .failure }) {
Expand Down Expand Up @@ -173,16 +173,54 @@ extension RunSummary {
}
}

/// One "Devices & Configurations" row.
/// One destination, as the device picker renders it (#439, A3a).
///
/// A1 built this to draw a row in the summary band's "Devices &
/// Configurations" card. A3a makes the same row the picker's option, which
/// is the whole of the "one control, not two" decision: the thing that
/// states a destination's pass/fail split and the thing that switches to
/// it are now the same element, so they cannot disagree and neither has to
/// be found twice.
///
/// That costs three fields the card did not need — the element handle to
/// switch to, the model to identify the destination by, and the run's own
/// outcome for the glyph. All three come from the same `Run`; nothing new
/// is read out of a bundle, so the differential is untouched.
struct DeviceRow {
let name: String
let osVersion: String
let model: String
/// This run's 1-based position in the report.
///
/// Rendered only when there is more than one run, and then only
/// because a report can hold two runs whose destination fields are
/// byte-for-byte equal: merging a bundle with itself, or two bundles
/// recorded on one simulator, which is what `ReproducibilityTests`'
/// duplicate-bundle case is built out of. Without it the picker offers
/// two options a reader cannot tell apart whenever the tallies also
/// match. The sidebar's answer was its `Identifier:` line, a path
/// digest that differed per run and meant nothing; a position is the
/// same fact in a form someone can act on.
///
/// Derived from the run's index, not read from a bundle, so it agrees
/// across backends by construction — the runs are built in argument
/// order on both.
let ordinal: Int
/// The `IdentifierPath` digest that addresses this run's two per-view
/// slices, `tests_<id>` and `logs_<id>`. Opaque by construction, which
/// is what makes it safe to interpolate into the option's handler.
let identifier: String
let status: Status
let tally: Tally

init(run: Run) {
init(run: Run, ordinal: Int) {
name = run.runDestination.name
osVersion = run.runDestination.targetDevice.osVersion
model = run.runDestination.targetDevice.model
identifier = run.runDestination.targetDevice.uniqueIdentifier
status = run.status
tally = run.tally
self.ordinal = ordinal
}
}

Expand Down
15 changes: 12 additions & 3 deletions Sources/XCTestHTMLReportCore/Classes/Models/Summary.swift
Loading
Loading