SPB Git

spb/metrika Public

Stata-class statistics, GPU-accelerated by Apple Silicon. Native Swift — no Electron, no Python runtime, no compromises.

Swift 92.4% HTML 3.3% R 3% Shell 1.3%

test(ui): add XCUITest console smoke per CLAUDE.md §9

- MetrikaUITests target (xcodegen scheme test action)
- launch-environment hook (DEBUG only): sandboxed app materializes the
  test CSV in its own container, since the runner cannot stage files
  across the sandbox boundary reliably
- queries predicate on AX 'value' (SwiftUI Text exposes value, not label)
- smoke: launch, auto-load dataset, type gen + reg robust, assert
  regression table and sidebar variable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
simon-pierre boucher committed 6 days ago (Aug 5, 2026) parent 8c017e2

Showing 5 changed files with +335 and −0

modified Metrika.xcodeproj/project.pbxproj +119 −0
@@ -12,18 +12,31 @@
12 12 7E936A23CCC90823D6B08BCC /* SessionModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0453FB0F256E853D2C2F481C /* SessionModel.swift */; };
13 13 8B0CDB213D64E3EE87593808 /* ConsoleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 159695CF89D6BB25DF883F07 /* ConsoleView.swift */; };
14 14 9BE65709781719FAE0CA740A /* MetrikaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */; };
15 + CB602D8A4EDDF5D0FBB0CFB7 /* ConsoleSmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */; };
15 16 DDCC2DC487AF9FAC7D6B8E3E /* PlotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AD551AA707CD1853C3D6D21 /* PlotView.swift */; };
16 17 /* End PBXBuildFile section */
17 18
19 +/* Begin PBXContainerItemProxy section */
20 + 13CEFED09749BEF836D4CD43 /* PBXContainerItemProxy */ = {
21 + isa = PBXContainerItemProxy;
22 + containerPortal = 8571D0059EB0A97DE47F96F7 /* Project object */;
23 + proxyType = 1;
24 + remoteGlobalIDString = 5E03B33ABE9DE3162C326F87;
25 + remoteInfo = Metrika;
26 + };
27 +/* End PBXContainerItemProxy section */
28 +
18 29 /* Begin PBXFileReference section */
19 30 0404FDEAF8A57414EFB3C649 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
20 31 0453FB0F256E853D2C2F481C /* SessionModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionModel.swift; sourceTree = "<group>"; };
21 32 159695CF89D6BB25DF883F07 /* ConsoleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleView.swift; sourceTree = "<group>"; };
22 33 24D84DAB354D702D8575D587 /* MetrikaKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = MetrikaKit; path = MetrikaKit; sourceTree = SOURCE_ROOT; };
34 + 2B7CDF1616657B2E1118696A /* MetrikaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MetrikaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
23 35 4AD551AA707CD1853C3D6D21 /* PlotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlotView.swift; sourceTree = "<group>"; };
24 36 5A0213CC9FE05A339DEEFA28 /* Metrika.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Metrika.app; sourceTree = BUILT_PRODUCTS_DIR; };
25 37 6E94C5817E96C83B888FB579 /* Metrika.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Metrika.entitlements; sourceTree = "<group>"; };
26 38 9C5393DC050C555E4FB69FC0 /* MetrikaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetrikaApp.swift; sourceTree = "<group>"; };
39 + AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSmokeTests.swift; sourceTree = "<group>"; };
27 40 /* End PBXFileReference section */
28 41
29 42 /* Begin PBXFrameworksBuildPhase section */
@@ -42,10 +55,19 @@
42 55 isa = PBXGroup;
43 56 children = (
44 57 5A0213CC9FE05A339DEEFA28 /* Metrika.app */,
58 + 2B7CDF1616657B2E1118696A /* MetrikaUITests.xctest */,
45 59 );
46 60 name = Products;
47 61 sourceTree = "<group>";
48 62 };
63 + 16F58F4F30568D46FED9725C /* Tests */ = {
64 + isa = PBXGroup;
65 + children = (
66 + 8DB762C8E40DC80A320B948A /* UITests */,
67 + );
68 + path = Tests;
69 + sourceTree = "<group>";
70 + };
49 71 58DB3AB5F820BD1E6C82E1CB /* Sources */ = {
50 72 isa = PBXGroup;
51 73 children = (
@@ -65,6 +87,14 @@
65 87 name = Packages;
66 88 sourceTree = "<group>";
67 89 };
90 + 8DB762C8E40DC80A320B948A /* UITests */ = {
91 + isa = PBXGroup;
92 + children = (
93 + AB7ECE59F86249B3A25B17A4 /* ConsoleSmokeTests.swift */,
94 + );
95 + path = UITests;
96 + sourceTree = "<group>";
97 + };
68 98 A905F8EA05CC983967935417 /* Metrika */ = {
69 99 isa = PBXGroup;
70 100 children = (
@@ -80,6 +110,7 @@
80 110 children = (
81 111 A905F8EA05CC983967935417 /* Metrika */,
82 112 63F77F28B4EF42E0407BEC4F /* Packages */,
113 + 16F58F4F30568D46FED9725C /* Tests */,
83 114 133558B8D49331BB5120F8F8 /* Products */,
84 115 );
85 116 sourceTree = "<group>";
@@ -87,6 +118,24 @@
87 118 /* End PBXGroup section */
88 119
89 120 /* Begin PBXNativeTarget section */
121 + 50B025D4EDA8EDC95554C858 /* MetrikaUITests */ = {
122 + isa = PBXNativeTarget;
123 + buildConfigurationList = 6538F0F675B5443C747BB776 /* Build configuration list for PBXNativeTarget "MetrikaUITests" */;
124 + buildPhases = (
125 + 0CBD8711F5ED186EFD33CFF6 /* Sources */,
126 + );
127 + buildRules = (
128 + );
129 + dependencies = (
130 + 0F2156F03062FA09F4D1A975 /* PBXTargetDependency */,
131 + );
132 + name = MetrikaUITests;
133 + packageProductDependencies = (
134 + );
135 + productName = MetrikaUITests;
136 + productReference = 2B7CDF1616657B2E1118696A /* MetrikaUITests.xctest */;
137 + productType = "com.apple.product-type.bundle.ui-testing";
138 + };
90 139 5E03B33ABE9DE3162C326F87 /* Metrika */ = {
91 140 isa = PBXNativeTarget;
92 141 buildConfigurationList = A087F0DEACCF786E9B7CC9A5 /* Build configuration list for PBXNativeTarget "Metrika" */;
@@ -116,6 +165,10 @@
116 165 BuildIndependentTargetsInParallel = YES;
117 166 LastUpgradeCheck = 1430;
118 167 TargetAttributes = {
168 + 50B025D4EDA8EDC95554C858 = {
169 + ProvisioningStyle = Automatic;
170 + TestTargetID = 5E03B33ABE9DE3162C326F87;
171 + };
119 172 5E03B33ABE9DE3162C326F87 = {
120 173 ProvisioningStyle = Automatic;
121 174 };
@@ -139,6 +192,7 @@
139 192 projectRoot = "";
140 193 targets = (
141 194 5E03B33ABE9DE3162C326F87 /* Metrika */,
195 + 50B025D4EDA8EDC95554C858 /* MetrikaUITests */,
142 196 );
143 197 };
144 198 /* End PBXProject section */
@@ -155,6 +209,14 @@
155 209 /* End PBXResourcesBuildPhase section */
156 210
157 211 /* Begin PBXSourcesBuildPhase section */
212 + 0CBD8711F5ED186EFD33CFF6 /* Sources */ = {
213 + isa = PBXSourcesBuildPhase;
214 + buildActionMask = 2147483647;
215 + files = (
216 + CB602D8A4EDDF5D0FBB0CFB7 /* ConsoleSmokeTests.swift in Sources */,
217 + );
218 + runOnlyForDeploymentPostprocessing = 0;
219 + };
158 220 C8918D5868AE76549F9247E4 /* Sources */ = {
159 221 isa = PBXSourcesBuildPhase;
160 222 buildActionMask = 2147483647;
@@ -168,6 +230,14 @@
168 230 };
169 231 /* End PBXSourcesBuildPhase section */
170 232
233 +/* Begin PBXTargetDependency section */
234 + 0F2156F03062FA09F4D1A975 /* PBXTargetDependency */ = {
235 + isa = PBXTargetDependency;
236 + target = 5E03B33ABE9DE3162C326F87 /* Metrika */;
237 + targetProxy = 13CEFED09749BEF836D4CD43 /* PBXContainerItemProxy */;
238 + };
239 +/* End PBXTargetDependency section */
240 +
171 241 /* Begin XCBuildConfiguration section */
172 242 3CB58B068179C9DB2CD1CAC1 /* Release */ = {
173 243 isa = XCBuildConfiguration;
@@ -225,6 +295,26 @@
225 295 };
226 296 name = Release;
227 297 };
298 + 441F73FC566D118C4502BB84 /* Release */ = {
299 + isa = XCBuildConfiguration;
300 + buildSettings = {
301 + ARCHS = arm64;
302 + BUNDLE_LOADER = "$(TEST_HOST)";
303 + CODE_SIGN_STYLE = Automatic;
304 + COMBINE_HIDPI_IMAGES = YES;
305 + GENERATE_INFOPLIST_FILE = YES;
306 + LD_RUNPATH_SEARCH_PATHS = (
307 + "$(inherited)",
308 + "@executable_path/Frameworks",
309 + "@loader_path/Frameworks",
310 + );
311 + PRODUCT_BUNDLE_IDENTIFIER = ai.spboucher.metrika.uitests;
312 + SDKROOT = macosx;
313 + SWIFT_VERSION = 6.0;
314 + TEST_TARGET_NAME = Metrika;
315 + };
316 + name = Release;
317 + };
228 318 4D5E4B6EED8197B03EAD7E51 /* Debug */ = {
229 319 isa = XCBuildConfiguration;
230 320 buildSettings = {
@@ -252,6 +342,26 @@
252 342 };
253 343 name = Debug;
254 344 };
345 + A29A62A39C08413EA3AEAA14 /* Debug */ = {
346 + isa = XCBuildConfiguration;
347 + buildSettings = {
348 + ARCHS = arm64;
349 + BUNDLE_LOADER = "$(TEST_HOST)";
350 + CODE_SIGN_STYLE = Automatic;
351 + COMBINE_HIDPI_IMAGES = YES;
352 + GENERATE_INFOPLIST_FILE = YES;
353 + LD_RUNPATH_SEARCH_PATHS = (
354 + "$(inherited)",
355 + "@executable_path/Frameworks",
356 + "@loader_path/Frameworks",
357 + );
358 + PRODUCT_BUNDLE_IDENTIFIER = ai.spboucher.metrika.uitests;
359 + SDKROOT = macosx;
360 + SWIFT_VERSION = 6.0;
361 + TEST_TARGET_NAME = Metrika;
362 + };
363 + name = Debug;
364 + };
255 365 C241F8724816C2E6FEFA98F2 /* Release */ = {
256 366 isa = XCBuildConfiguration;
257 367 buildSettings = {
@@ -354,6 +464,15 @@
354 464 defaultConfigurationIsVisible = 0;
355 465 defaultConfigurationName = Debug;
356 466 };
467 + 6538F0F675B5443C747BB776 /* Build configuration list for PBXNativeTarget "MetrikaUITests" */ = {
468 + isa = XCConfigurationList;
469 + buildConfigurations = (
470 + A29A62A39C08413EA3AEAA14 /* Debug */,
471 + 441F73FC566D118C4502BB84 /* Release */,
472 + );
473 + defaultConfigurationIsVisible = 0;
474 + defaultConfigurationName = Debug;
475 + };
357 476 A087F0DEACCF786E9B7CC9A5 /* Build configuration list for PBXNativeTarget "Metrika" */ = {
358 477 isa = XCConfigurationList;
359 478 buildConfigurations = (
added Metrika.xcodeproj/xcshareddata/xcschemes/Metrika.xcscheme +104 −0
@@ -0,0 +1,104 @@
1 +<?xml version="1.0" encoding="UTF-8"?>
2 +<Scheme
3 + LastUpgradeVersion = "1430"
4 + version = "1.7">
5 + <BuildAction
6 + parallelizeBuildables = "YES"
7 + buildImplicitDependencies = "YES"
8 + runPostActionsOnFailure = "NO">
9 + <BuildActionEntries>
10 + <BuildActionEntry
11 + buildForTesting = "YES"
12 + buildForRunning = "YES"
13 + buildForProfiling = "YES"
14 + buildForArchiving = "YES"
15 + buildForAnalyzing = "YES">
16 + <BuildableReference
17 + BuildableIdentifier = "primary"
18 + BlueprintIdentifier = "5E03B33ABE9DE3162C326F87"
19 + BuildableName = "Metrika.app"
20 + BlueprintName = "Metrika"
21 + ReferencedContainer = "container:Metrika.xcodeproj">
22 + </BuildableReference>
23 + </BuildActionEntry>
24 + </BuildActionEntries>
25 + </BuildAction>
26 + <TestAction
27 + buildConfiguration = "Debug"
28 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
29 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
30 + shouldUseLaunchSchemeArgsEnv = "YES"
31 + onlyGenerateCoverageForSpecifiedTargets = "NO">
32 + <MacroExpansion>
33 + <BuildableReference
34 + BuildableIdentifier = "primary"
35 + BlueprintIdentifier = "5E03B33ABE9DE3162C326F87"
36 + BuildableName = "Metrika.app"
37 + BlueprintName = "Metrika"
38 + ReferencedContainer = "container:Metrika.xcodeproj">
39 + </BuildableReference>
40 + </MacroExpansion>
41 + <Testables>
42 + <TestableReference
43 + skipped = "NO"
44 + parallelizable = "NO">
45 + <BuildableReference
46 + BuildableIdentifier = "primary"
47 + BlueprintIdentifier = "50B025D4EDA8EDC95554C858"
48 + BuildableName = "MetrikaUITests.xctest"
49 + BlueprintName = "MetrikaUITests"
50 + ReferencedContainer = "container:Metrika.xcodeproj">
51 + </BuildableReference>
52 + </TestableReference>
53 + </Testables>
54 + <CommandLineArguments>
55 + </CommandLineArguments>
56 + </TestAction>
57 + <LaunchAction
58 + buildConfiguration = "Debug"
59 + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
60 + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
61 + launchStyle = "0"
62 + useCustomWorkingDirectory = "NO"
63 + ignoresPersistentStateOnLaunch = "NO"
64 + debugDocumentVersioning = "YES"
65 + debugServiceExtension = "internal"
66 + allowLocationSimulation = "YES">
67 + <BuildableProductRunnable
68 + runnableDebuggingMode = "0">
69 + <BuildableReference
70 + BuildableIdentifier = "primary"
71 + BlueprintIdentifier = "5E03B33ABE9DE3162C326F87"
72 + BuildableName = "Metrika.app"
73 + BlueprintName = "Metrika"
74 + ReferencedContainer = "container:Metrika.xcodeproj">
75 + </BuildableReference>
76 + </BuildableProductRunnable>
77 + <CommandLineArguments>
78 + </CommandLineArguments>
79 + </LaunchAction>
80 + <ProfileAction
81 + buildConfiguration = "Release"
82 + shouldUseLaunchSchemeArgsEnv = "YES"
83 + savedToolIdentifier = ""
84 + useCustomWorkingDirectory = "NO"
85 + debugDocumentVersioning = "YES">
86 + <BuildableProductRunnable
87 + runnableDebuggingMode = "0">
88 + <BuildableReference
89 + BuildableIdentifier = "primary"
90 + BlueprintIdentifier = "5E03B33ABE9DE3162C326F87"
91 + BuildableName = "Metrika.app"
92 + BlueprintName = "Metrika"
93 + ReferencedContainer = "container:Metrika.xcodeproj">
94 + </BuildableReference>
95 + </BuildableProductRunnable>
96 + </ProfileAction>
97 + <AnalyzeAction
98 + buildConfiguration = "Debug">
99 + </AnalyzeAction>
100 + <ArchiveAction
101 + buildConfiguration = "Release"
102 + revealArchiveInOrganizer = "YES">
103 + </ArchiveAction>
104 +</Scheme>
modified Metrika/Sources/SessionModel.swift +11 −0
@@ -51,6 +51,17 @@ final class SessionModel {
51 51 isError: true
52 52 ))
53 53 }
54 + #if DEBUG
55 + // XCUITest hook: the sandboxed app cannot read files staged by the
56 + // test runner, so the test passes CSV content via the environment
57 + // and the app materializes it inside its own container.
58 + if let csv = ProcessInfo.processInfo.environment["METRIKA_UITEST_CSV"] {
59 + let url = FileManager.default.temporaryDirectory
60 + .appendingPathComponent("uitest_smoke.csv")
61 + try? Data(csv.utf8).write(to: url)
62 + Task { await run("use \(url.path)") }
63 + }
64 + #endif
54 65 }
55 66
56 67 func run(_ command: String) async {
added Tests/UITests/ConsoleSmokeTests.swift +73 −0
@@ -0,0 +1,73 @@
1 +//
2 +// ConsoleSmokeTests.swift
3 +// Metrika
4 +//
5 +// Author: Simon-Pierre Boucher
6 +// Contact: contact@spboucher.ai
7 +// Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
8 +//
9 +
10 +import XCTest
11 +
12 +/// XCUITest smoke (CLAUDE.md §9): launch the app, load a sample dataset,
13 +/// run a regression, and assert the output table appears in the console.
14 +///
15 +/// The sandboxed app cannot read files staged by the test runner (and its
16 +/// container may be recreated between runs), so the dataset travels via
17 +/// `METRIKA_UITEST_CSV` in the launch environment; the app writes it into
18 +/// its own container and `use`s it on startup.
19 +///
20 +/// SwiftUI `Text` exposes content through the accessibility **value**
21 +/// attribute, so all queries predicate on `value`, not `label`.
22 +final class ConsoleSmokeTests: XCTestCase {
23 +
24 + private func staticText(
25 + containing needle: String, in app: XCUIApplication
26 + ) -> XCUIElement {
27 + app.staticTexts.matching(
28 + NSPredicate(format: "value CONTAINS %@", needle)
29 + ).firstMatch
30 + }
31 +
32 + func testLoadDatasetAndRegress() throws {
33 + continueAfterFailure = false
34 +
35 + let app = XCUIApplication()
36 + app.launchEnvironment["METRIKA_UITEST_CSV"] = """
37 + revenue,price,region,firm_id
38 + 120.5,10.2,1,1
39 + 98.3,12.1,1,1
40 + 143.2,9.8,2,2
41 + 110.0,11.5,2,2
42 + 155.9,8.9,3,3
43 + 101.2,12.8,3,3
44 + 133.4,10.1,1,4
45 + 88.7,13.5,2,4
46 + 149.1,9.2,3,5
47 + 115.6,11.0,1,5
48 + """
49 + app.launch()
50 +
51 + // Startup hook loads the dataset.
52 + let loaded = staticText(containing: "10 observations, 4 variables", in: app)
53 + XCTAssertTrue(loaded.waitForExistence(timeout: 10), "dataset did not load")
54 +
55 + let input = app.textFields.firstMatch
56 + XCTAssertTrue(input.waitForExistence(timeout: 5), "command field missing")
57 + input.click()
58 + input.typeText("gen log_rev = ln(revenue)\n")
59 + input.typeText("reg log_rev price, robust\n")
60 +
61 + // Regression output: robust banner and coefficient table.
62 + let banner = staticText(containing: "Linear regression (robust)", in: app)
63 + XCTAssertTrue(banner.waitForExistence(timeout: 10), "regression output missing")
64 + XCTAssertTrue(
65 + staticText(containing: "Coefficient", in: app).exists,
66 + "coefficient table missing"
67 + )
68 +
69 + // Sidebar reflects the generated variable.
70 + let sidebar = staticText(containing: "log_rev", in: app)
71 + XCTAssertTrue(sidebar.waitForExistence(timeout: 5), "sidebar missing log_rev")
72 + }
73 +}
modified project.yml +28 −0
@@ -9,7 +9,35 @@ packages:
9 9 MetrikaKit:
10 10 path: MetrikaKit
11 11
12 +schemes:
13 + Metrika:
14 + build:
15 + targets:
16 + Metrika: all
17 + run:
18 + config: Debug
19 + test:
20 + config: Debug
21 + targets:
22 + - MetrikaUITests
23 +
12 24 targets:
25 + MetrikaUITests:
26 + type: bundle.ui-testing
27 + platform: macOS
28 + sources:
29 + - Tests/UITests
30 + dependencies:
31 + - target: Metrika
32 + settings:
33 + base:
34 + PRODUCT_BUNDLE_IDENTIFIER: ai.spboucher.metrika.uitests
35 + SWIFT_VERSION: "6.0"
36 + ARCHS: arm64
37 + GENERATE_INFOPLIST_FILE: YES
38 + TEST_TARGET_NAME: Metrika
39 + CODE_SIGN_STYLE: Automatic
40 +
13 41 Metrika:
14 42 type: application
15 43 platform: macOS
16 44