Skip to content

Commit 44202eb

Browse files
authored
test: adds e2e support for tag (#219)
Signed-off-by: Allain Magyar <[email protected]>
1 parent 6218165 commit 44202eb

27 files changed

+229
-209
lines changed

.github/workflows/build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727

2828
- uses: maxim-lobanov/setup-xcode@v1
2929
with:
30-
xcode-version: '26.0.0'
30+
xcode-version: '26'
3131

3232
# - name: Install lcov
3333
# run: brew install [email protected] && brew link --overwrite --force [email protected]

E2E/TestFramework/BDD/Feature.swift

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,11 @@ import SwiftHamcrest
44

55
open class Feature: XCTestCase {
66
let id: String = UUID().uuidString
7-
open var currentScenario: Scenario? = nil
8-
9-
open func title() -> String {
10-
fatalError("Set feature title")
11-
}
12-
13-
open func description() -> String {
14-
return ""
15-
}
7+
public var currentScenario: Scenario? = nil
8+
9+
open var tags: [String] { return [] }
10+
open var title: String { fatalError("Set feature title") }
11+
open var narrative: String { return "" }
1612

1713
/// our lifecycle starts after xctest is ending
1814
public override func tearDown() async throws {
@@ -38,18 +34,18 @@ open class Feature: XCTestCase {
3834
public override class func tearDown() {
3935
if (TestConfiguration.started) {
4036
let semaphore = DispatchSemaphore(value: 0)
41-
Task.detached {
37+
Task.detached(priority: .userInitiated) {
4238
try await TestConfiguration.shared().endCurrentFeature()
4339
semaphore.signal()
4440
}
4541
semaphore.wait()
4642
}
47-
super.tearDown()
43+
XCTestCase.tearDown()
4844
}
4945

5046
func run() async throws {
5147
let currentTestMethodName = self.name
52-
if currentScenario == nil {
48+
guard let scenario = currentScenario else {
5349
let rawMethodName = currentTestMethodName.split(separator: " ").last?.dropLast() ?? "yourTestMethod"
5450

5551
let errorMessage = """
@@ -66,9 +62,10 @@ open class Feature: XCTestCase {
6662
"""
6763
throw ConfigurationError.missingScenario(errorMessage)
6864
}
69-
if currentScenario!.disabled {
70-
throw XCTSkip("Scenario '\(currentScenario!.name)' in test method \(currentTestMethodName) is disabled.")
65+
if scenario.disabled {
66+
throw XCTSkip("Scenario '\(scenario.name)' in test method \(currentTestMethodName) is disabled.")
7167
}
68+
7269
try await TestConfiguration.setUpInstance()
7370

7471
if let parameterizedScenario = currentScenario as? ParameterizedScenario {

E2E/TestFramework/BDD/Scenario.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ public class Scenario {
88
var disabled: Bool = false
99
var feature: Feature?
1010
var parameters: [String: String]?
11-
11+
var tags: [String] = []
12+
1213
private var lastContext: String = ""
1314

1415
public init(_ title: String, parameters: [String: String] = [:]) {
@@ -31,6 +32,11 @@ public class Scenario {
3132
steps.append(stepInstance)
3233
}
3334

35+
public func tags(_ tags: String...) -> Scenario {
36+
self.tags.append(contentsOf: tags)
37+
return self
38+
}
39+
3440
public func given(_ step: String) -> Scenario {
3541
lastContext = "Given"
3642
addStep(step)
@@ -54,7 +60,7 @@ public class Scenario {
5460
addStep(step)
5561
return self
5662
}
57-
63+
5864
public func and(_ step: String) -> Scenario {
5965
if (lastContext.isEmpty) {
6066
fatalError("Trying to add an [and] step without previous context.")
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import Foundation
2+
3+
struct TagFilter {
4+
private let expression: String
5+
6+
/// Initializes the filter with the raw tag expression string from the environment.
7+
init(from expression: String?) {
8+
self.expression = expression?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
9+
}
10+
11+
/// Determines if a scenario with the given tags should be executed based on the expression.
12+
func shouldRun(scenarioTags: [String]) -> Bool {
13+
// If there's no expression, run everything.
14+
if expression.isEmpty {
15+
return true
16+
}
17+
18+
let scenarioTagSet = Set(scenarioTags)
19+
20+
// Split by "or" to handle the lowest precedence operator first.
21+
// If any of these OR clauses are true, the whole expression is true.
22+
let orClauses = expression.components(separatedBy: " or ")
23+
24+
for orClause in orClauses {
25+
// Check if this "AND" group is satisfied.
26+
if evaluateAndClause(clause: orClause, scenarioTags: scenarioTagSet) {
27+
return true
28+
}
29+
}
30+
31+
// If none of the OR clauses were satisfied, the expression is false.
32+
return false
33+
}
34+
35+
/// Evaluates a sub-expression containing only "and" and "not" conditions.
36+
/// This clause is true only if ALL of its conditions are met.
37+
private func evaluateAndClause(clause: String, scenarioTags: Set<String>) -> Bool {
38+
let andConditions = clause.components(separatedBy: " and ")
39+
40+
for condition in andConditions {
41+
if !evaluateCondition(condition: condition, scenarioTags: scenarioTags) {
42+
return false // If any condition is false, the whole AND clause is false.
43+
}
44+
}
45+
46+
// If all conditions passed, the AND clause is true.
47+
return true
48+
}
49+
50+
/// Evaluates a single tag condition (e.g., "smoke" or "not wip").
51+
private func evaluateCondition(condition: String, scenarioTags: Set<String>) -> Bool {
52+
let trimmedCondition = condition.trimmingCharacters(in: .whitespacesAndNewlines)
53+
54+
if trimmedCondition.hasPrefix("not ") {
55+
let tag = String(trimmedCondition.dropFirst(4))
56+
return !scenarioTags.contains(tag)
57+
} else {
58+
let tag = trimmedCondition
59+
return scenarioTags.contains(tag)
60+
}
61+
}
62+
}

E2E/TestFramework/Configuration/TestConfiguration.swift

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,19 @@ open class TestConfiguration: ITestConfiguration {
100100
}
101101

102102
func runSteps(_ scenario: Scenario) async throws -> ScenarioOutcome {
103+
let tagString = TestConfiguration.shared().environment["TAGS"]
104+
let tagFilter = TagFilter(from: tagString)
105+
let scenarioTags = scenario.feature!.tags + scenario.tags
106+
if !tagFilter.shouldRun(scenarioTags: scenarioTags) {
107+
scenario.disabled = true
108+
}
103109
if scenario.disabled {
104-
return ScenarioOutcome(scenario)
110+
let outcome = ScenarioOutcome(scenario)
111+
outcome.status = .skipped
112+
return outcome
105113
}
106114

115+
107116
let scenarioOutcome = ScenarioOutcome(scenario)
108117
scenarioOutcome.start()
109118

@@ -222,6 +231,9 @@ open class TestConfiguration: ITestConfiguration {
222231
currentFeatureOut.scenarioOutcomes.append(scenarioOutcome)
223232
try await report(.AFTER_SCENARIO, scenarioOutcome)
224233
try await tearDownActors()
234+
if (scenarioOutcome.status == .skipped) {
235+
throw XCTSkip()
236+
}
225237
}
226238

227239
public func afterFeature(_ featureOutcome: FeatureOutcome) async throws {
@@ -252,7 +264,7 @@ open class TestConfiguration: ITestConfiguration {
252264
/// signals the suite has ended
253265
public func end() {
254266
let semaphore = DispatchSemaphore(value: 0)
255-
Task.detached {
267+
Task.detached(priority: .userInitiated) {
256268
self.suiteOutcome.end()
257269
try await self.afterFeatures(self.suiteOutcome.featureOutcomes)
258270
try await self.tearDownInstance()
@@ -335,13 +347,14 @@ open class TestConfiguration: ITestConfiguration {
335347
instance.suiteOutcome.start()
336348
self.instance = instance
337349

338-
do {
339-
try await instance.setUp()
340-
try await instance.setUpReporters()
341-
try await instance.setUpSteps()
342-
} catch {
343-
throw ConfigurationError.setup(message: error.localizedDescription)
344-
}
350+
print("Setting up configuration instance")
351+
try await instance.setUp()
352+
353+
print("Setting up reporters")
354+
try await instance.setUpReporters()
355+
356+
print("Setting up steps")
357+
try await instance.setUpSteps()
345358

346359
/// setup hamcrest to update variable if failed
347360
HamcrestReportFunction = { message, file, line in

E2E/TestFramework/Errors/ConfigurationError.swift

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,4 @@
11
open class ConfigurationError {
2-
public final class setup: BaseError {
3-
public init(message: String, file: StaticString = #file, line: UInt = #line) {
4-
super.init(message: message, error: "Configuration error", file: file, line: line)
5-
}
6-
}
7-
82
public final class missingScenario: Error, CustomStringConvertible {
93
public var errorDescription: String
104
public var failureReason: String = "Missing scenario"

E2E/TestFramework/Outcome/FeatureOutcome.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ public class FeatureOutcome {
6767
} else {
6868
// This case should ideally not be reached if the above logic is complete.
6969
// Could default to .broken if there's an unexpected mix.
70-
print("Warning: FeatureOutcome for '\(feature.title())' has an undetermined status mix.")
70+
// print("Warning: FeatureOutcome for '\(feature.title())' has an undetermined status mix.")
7171
self.status = .broken // Default for unexpected mixed states
7272
}
7373
}

E2E/TestFramework/Report/AllureReporter.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -266,14 +266,14 @@ public class AllureReporter: Reporter {
266266
testCaseId: calculatedTestCaseId,
267267
name: scenario.name,
268268
fullName: scenarioUniqueName,
269-
description: scenario.feature?.description(),
269+
description: scenario.feature?.narrative,
270270
labels: [
271271
AllureLabel(name: "host", value: ProcessInfo.processInfo.hostName),
272272
AllureLabel(name: "thread", value: getCurrentPid()),
273273
AllureLabel(name: "package", value: generatePackageName(fromFeatureType: type(of: scenario.feature!))),
274274
AllureLabel(name: "language", value: "swift"),
275275
AllureLabel(name: "framework", value: "identus-e2e-framework"),
276-
AllureLabel(name: "feature", value: scenario.feature!.title()),
276+
AllureLabel(name: "feature", value: scenario.feature!.title),
277277
//AllureLabel(name: "suite", value: "suite"), // FIXME: property? config?
278278
//AllureLabel(name: "epic", value: "suite"), // FIXME: property? config?
279279
//AllureLabel(name: "story", value: scenario.name)
@@ -386,7 +386,7 @@ public class AllureReporter: Reporter {
386386
container.stop = millisecondsSince1970(from: featureOutcome.endTime)
387387
if (featureOutcome.status == .broken || featureOutcome.status == .failed),
388388
let featureErr = featureOutcome.error { // From your FeatureOutcome model
389-
let fixtureName = "Feature Level Issue: \(featureOutcome.feature.title())"
389+
let fixtureName = "Feature Level Issue: \(featureOutcome.feature.title)"
390390
let problemFixture = AllureFixtureResult(
391391
name: fixtureName,
392392
status: mapFrameworkStatusToAllureStatus(featureOutcome.status),

E2E/TestFramework/Report/ConsoleReporter.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ public class ConsoleReporter: Reporter {
1212
public func beforeFeature(_ feature: Feature) async throws {
1313
print()
1414
print("---")
15-
print("Feature:", feature.title())
15+
print("Feature:", feature.title)
1616
}
1717

1818
public func beforeScenario(_ scenario: Scenario) async throws {

E2E/TestFramework/Report/DebugReporter.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ public class DebugReporter: Reporter {
77
public required init() {}
88

99
public func beforeFeature(_ feature: Feature) async throws {
10-
if debug { print("Before Feature:", feature.title()) }
10+
if debug { print("Before Feature:", feature.title) }
1111
}
1212

1313
public func beforeScenario(_ scenario: Scenario) async throws {
@@ -35,7 +35,7 @@ public class DebugReporter: Reporter {
3535
}
3636

3737
public func afterFeature(_ featureOutcome: FeatureOutcome) async throws {
38-
print("After Feature", featureOutcome.feature.title())
38+
print("After Feature", featureOutcome.feature.title)
3939
}
4040

4141
public func afterFeatures(_ featuresOutcome: [FeatureOutcome]) async throws {

0 commit comments

Comments
 (0)