diff --git a/.gitignore b/.gitignore index 3aee842d..60f8f328 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ dist/ *.tsbuildinfo *.tgz +# Python bytecode caches +__pycache__/ +*.py[cod] + # Local environment files .env .env.* diff --git a/Memory/MARKETING.md b/Memory/MARKETING.md index 35ead629..6e6ae985 100644 --- a/Memory/MARKETING.md +++ b/Memory/MARKETING.md @@ -72,6 +72,8 @@ The runner defaults to the iOS/iPadOS 26.5 simulator runtime so installing a new runtime cannot silently change the marketing UI or select another same-name device. Set `TERMIND_MARKETING_RUNTIME` to a full CoreSimulator runtime identifier only when intentionally updating the runtime used for a capture set. +An iPad-only selection builds on the selected iPad and does not require an iPhone +simulator to be installed. Each locale launches a separate test process with matching Apple language, region, and locale arguments. `TERMIND_MARKETING_LOCALE` selects that process's fixture @@ -119,10 +121,32 @@ simulated sidebar or back buttons to the top-level Metrics and Assistant pages. Regenerate only the matching nine iPad device frames and App Store boards 04-06; first boards use iPad SFTP and remain unchanged. +The iPad SSH and SFTP fixtures mount the production `MobileRootView` and workspace +pages, with deterministic in-memory data supplied through the existing +`MobileWorkspaceConnectionServices`. They select Workspaces, collapse the native +sidebar, and show the current top tabs, workspace switcher and action menu. Keep +the iPhone-only legacy scene wrappers out of this iPad path. Terminal output stays +English in every locale. The approved topbar capture set uses iPadOS 27.0: + +```sh +TERMIND_MARKETING_SCENES=ssh,sftp \ +TERMIND_MARKETING_DEVICES=ipad \ +TERMIND_MARKETING_RUNTIME=com.apple.CoreSimulator.SimRuntime.iOS-27-0 \ +Scripts/run-mobile-marketing-screenshots.sh +``` + +Refresh those six device frames and iPad boards 02–03, plus the iPad SFTP image in +all nine three-device first boards. Other frames and boards are outside that set. + The runner sets a deterministic `9:41` status bar, charged battery, Wi-Fi, and cellular state. It sets the iPad orientation and captures its landscape display with `devicectl`, verifying `2752x2064` before acknowledging each capture. The iPhone uses `simctl` screenshots. No post-capture rotation is required. +Each locale process receives a fresh `TERMIND_MARKETING_CAPTURE_ID`; the runner +ignores ready markers from earlier processes. Captures are staged under the +derived-data directory and copied to the source tree only after the tests and +expected capture count pass. Back up raw images and a SHA-256 manifest before +running a refresh. Whenever SFTP, Metrics, Assistant navigation, or system chrome changes, recapture the affected device and every locale. A marketing fixture may be updated to expose @@ -136,6 +160,8 @@ mounts a second RootView or duplicates a terminal/transcript surface. Before it publishes the ready marker, it requires both `NSApp.isActive` and `window.isKeyWindow`, allows the focused appearance to render, and requires native 2× backing scale. Focus is checked again when the capture is acknowledged. +The focus retry loop also requires activation to survive the 300 ms appearance +render interval. Do not run another Mac app activation alongside a capture. Generate all three localized Workspace sources: @@ -459,3 +485,57 @@ The following failures persisted in unchanged tests: Logs and `.xcresult` bundles remain in `.build/marketing-forwarding-refresh/`. No production behavior or unrelated tests were changed to address these failures. + +The later 2026-09-19 navigation topbar refresh replaced Mac Metrics overview and +Vault in all three languages (`2360x1480`) and iPad SSH/SFTP in all three languages +(`2752x2064`, iPadOS 27.0, iPad Pro 13-inch M5 landscape, 9:41). All six Mac sources +passed active/key checks before capture and after acknowledgement; visual and +pixel checks confirmed colored traffic lights and the current `plus` icons. +The iPad captures use the actual root/workspace views, with Workspaces selected, +the sidebar collapsed and the native topbar and bottom controls fully visible. + +Only the six matching iPad device frames (`2960x2290`) were regenerated. In the +verified `Termind` Figma file, the refresh replaced Mac boards 03/05, iPad boards +02/03 and the iPad SFTP image in every platform's board 01, across all locales. +English was reviewed first. Readback confirmed all 21 image fills match their +local source PNG bytes, and every other reported node property stayed unchanged, +including copy, fonts, geometry, FIT, shadows and stacking. The 21 JPG exports use +`save_screenshots` at 1×, retain the original dimensions and have opaque RGB pixels +with embedded sRGB profiles. Profile metadata was added without re-encoding or +changing decoded pixels. + +The complete pre-capture image backup, SHA-256 manifests, focus evidence, Figma +readbacks, contact sheets and validation logs are in +`.build/marketing-topbar-refresh/`. Exact-path auditing found 39 changed and 93 +unchanged images, with no additions or removals: 45 captures, 33 device frames and +54 App Store JPGs. A profile audit also found 23 untouched baseline JPGs without an +embedded ICC profile; this corrects the earlier blanket statement about all 54 +files being tagged. Those 23 originals remain byte-identical to the backup. + +The website rebuilt successfully (17 pages, no errors or warnings), using the +existing image references. The English, Simplified Chinese and Traditional Chinese +homepages were checked visually, including their hero and SFTP images; `/zh/` +redirects to `/zh-hans/`. Full Swift formatting, Chinese localization, screenshot +shell syntax, generator Python syntax and whitespace checks passed. The Xcode +project and resolved package versions remain unchanged. Python bytecode caches +are now ignored by Git. + +All nine opt-in capture runs passed (six Mac runs and three iPad locale runs, +producing 12 sources). The complete TFM run finished with 1,719 passed tests, +one failed test and 23 skipped tests. Its only failure is the previously recorded +`MetricsDetailToolbarTests.legacyCompactSearchOpensTheNativeFieldAndKeepsItsQueryWhenWidened` +in all three language cases. `ScreenshotDemoTests`, `AppLanguageTests` and all +352 shared-framework tests passed. + +The TFI run completed 328 app tests and all 352 shared-framework tests: 679 passed +tests and one failed test. The unchanged +`MobileAssistantInteractionRenderingTests.interactionTextRemainsVisibleOnMaterial` +reported 12 OCR mismatches on iPadOS 27.0, including `Deployment` read as +`Deplovment` and `prod.example` read as `oroo.examole`. The existing capture build +excluded `MobileNavigationParityTests`; a normal pinned build and separate run of +all four tests passed, and the obsolete exclusion was removed from the runner. +After both main suites had finished, Xcode stalled collecting simulator diagnostics; +only its `simctl diagnose` child was stopped so the complete `.xcresult` could +finalize. No tests were interrupted. Both full-run result bundles and the +navigation supplement are retained in the evidence directory. Product code and +unrelated test assertions were not changed to address the failures. diff --git a/Scripts/run-mobile-marketing-screenshots.sh b/Scripts/run-mobile-marketing-screenshots.sh index 4fa08f61..08ca5f8e 100755 --- a/Scripts/run-mobile-marketing-screenshots.sh +++ b/Scripts/run-mobile-marketing-screenshots.sh @@ -54,10 +54,12 @@ run_capture() { zh-Hans) region=CN; apple_locale=zh_CN ;; zh-Hant) region=TW; apple_locale=zh_TW ;; esac + local capture_id="$(uuidgen)" local localized_xctestrun="$derived_data/Build/Products/Marketing-TFI-$language.xctestrun" cp "$marketing_xctestrun" "$localized_xctestrun" /usr/libexec/PlistBuddy \ -c "Add :TFITests:EnvironmentVariables:TERMIND_MARKETING_LOCALE string $language" \ + -c "Add :TFITests:EnvironmentVariables:TERMIND_MARKETING_CAPTURE_ID string $capture_id" \ -c "Set :TFITests:TestLanguage $language" \ -c "Set :TFITests:TestRegion $region" \ -c 'Add :TFITests:CommandLineArguments:0 string -AppleLanguages' \ @@ -74,25 +76,28 @@ run_capture() { local test_pid=$! local captures=0 local last_token="" + local capture_directory="$derived_data/captures/$device_id-$language" + local -a captured_paths destinations + mkdir -p "$capture_directory" while kill -0 "$test_pid" 2>/dev/null; do local app_container app_container="$(xcrun simctl get_app_container "$device_id" com.akinokaede.termind data 2>/dev/null || true)" local ready="$app_container/Documents/marketing-capture-control/ready" if [[ -n "$app_container" && -f "$ready" ]]; then - local feature device locale token - IFS=$'\t' read -r feature device locale token < "$ready" - if [[ -n "$token" && "$token" != "$last_token" ]]; then + local feature device locale token request_capture_id + IFS=$'\t' read -r feature device locale token request_capture_id < "$ready" + if [[ "$request_capture_id" == "$capture_id" && -n "$token" && "$token" != "$last_token" ]]; then local destination="$output_directory/$feature/$device-$locale.png" - mkdir -p "${destination:h}" + local captured="$capture_directory/$feature.png" if [[ "$device" == "ipad" ]]; then - rm -f "$destination" + rm -f "$captured" xcrun devicectl device capture screenshot \ --device "$device_id" \ - --destination "$destination" >/dev/null + --destination "$captured" >/dev/null local width height - width="$(sips -g pixelWidth "$destination" | awk '/pixelWidth/ { print $2 }')" - height="$(sips -g pixelHeight "$destination" | awk '/pixelHeight/ { print $2 }')" + width="$(sips -g pixelWidth "$captured" | awk '/pixelWidth/ { print $2 }')" + height="$(sips -g pixelHeight "$captured" | awk '/pixelHeight/ { print $2 }')" if [[ "$width" != "2752" || "$height" != "2064" ]]; then print -u2 "Unexpected iPad screenshot dimensions: ${width}x${height}" kill "$test_pid" 2>/dev/null || true @@ -101,8 +106,10 @@ run_capture() { exit 70 fi else - xcrun simctl io "$device_id" screenshot --type=png "$destination" >/dev/null + xcrun simctl io "$device_id" screenshot --type=png "$captured" >/dev/null fi + captured_paths+=("$captured") + destinations+=("$destination") touch "$app_container/Documents/marketing-capture-control/ack-$token" last_token="$token" (( captures += 1 )) @@ -123,12 +130,24 @@ run_capture() { print -u2 "Expected $expected captures for $name ($language), received $captures. See $test_log" exit 70 fi + for (( index = 1; index <= ${#captured_paths}; index++ )); do + mkdir -p "${destinations[$index]:h}" + cp "$captured_paths[$index]" "$destinations[$index]" + done } mkdir -p "$output_directory" -build_device_id="$(find_device "iPhone 17 Pro Max")" +if [[ ",$marketing_devices," == *,iphone,* ]]; then + build_device_name="iPhone 17 Pro Max" +elif [[ ",$marketing_devices," == *,ipad,* ]]; then + build_device_name="iPad Pro 13-inch (M5)" +else + print -u2 "Select at least one marketing device: iphone,ipad" + exit 64 +fi +build_device_id="$(find_device "$build_device_name")" if [[ -z "$build_device_id" ]]; then - print -u2 "Required simulator is unavailable: iPhone 17 Pro Max ($marketing_runtime)" + print -u2 "Required simulator is unavailable: $build_device_name ($marketing_runtime)" exit 69 fi xcodebuild \ @@ -137,7 +156,6 @@ xcodebuild \ -destination "id=$build_device_id" \ -onlyUsePackageVersionsFromResolvedFile \ -derivedDataPath "$derived_data" \ - EXCLUDED_SOURCE_FILE_NAMES=MobileNavigationParityTests.swift \ build-for-testing generated_xctestrun="$(find "$derived_data/Build/Products" -maxdepth 1 -name 'TFI_*.xctestrun' -print -quit)" diff --git a/TFITests/MobileMarketingScreenshotTests.swift b/TFITests/MobileMarketingScreenshotTests.swift index fa99f3f1..570bfe98 100644 --- a/TFITests/MobileMarketingScreenshotTests.swift +++ b/TFITests/MobileMarketingScreenshotTests.swift @@ -4,6 +4,7 @@ import SwiftUI import TermindApplication import TermindDomain import TermindPresentation +import TermindRemoteInfrastructure import Testing import UIKit @@ -34,7 +35,9 @@ struct MobileMarketingScreenshotTests { } } - if includes("sftp") { + if includes("sftp"), UIDevice.current.userInterfaceIdiom == .pad { + try await captureWorkspace(.sftp, language: language) + } else if includes("sftp") { let sftp = MobileSFTPSessionModel() try await sftp.attach( fileSystem: MarketingFileSystem(), path: "/srv/termind/releases/2026.09", @@ -63,8 +66,10 @@ struct MobileMarketingScreenshotTests { } } - if includes("ssh") { - let terminal = makeTerminal(language: language) + if includes("ssh"), UIDevice.current.userInterfaceIdiom == .pad { + try await captureWorkspace(.ssh, language: language) + } else if includes("ssh") { + let terminal = makeTerminal() try await capture("ssh", language: language) { NavigationStack { MarketingTerminalScene(terminal: terminal, language: language) } } @@ -96,8 +101,10 @@ struct MobileMarketingScreenshotTests { } } - private func makeApp(language: String) throws -> MobileAppModel { - let app = try MobileAppModel(inMemory: true) + private func makeApp( + language: String, connectionServices: MobileWorkspaceConnectionServices = .live + ) throws -> MobileAppModel { + let app = try MobileAppModel(inMemory: true, workspaceConnectionServices: connectionServices) let production = HostGroup(name: text(language, "Production", "生产环境", "正式環境")) let edge = HostGroup(name: text(language, "Edge", "边缘节点", "邊緣節點")) app.library.upsert(production) @@ -122,12 +129,52 @@ struct MobileMarketingScreenshotTests { return app } - private func makeTerminal(language: String) -> TerminalViewState { + private func captureWorkspace(_ kind: ConnectionHistoryEntry.Kind, language: String) async throws { + let output = terminalOutput + let services = MobileWorkspaceConnectionServices( + makeSSHSession: { receive, exit, shellIntegration in + receive(output) + return SSHSessionActor(output: receive, exit: exit, shellIntegration: shellIntegration) + }, + connectSSH: { _, _, _, _, _, _ in }, + connectSFTP: { _, _, _ in MarketingFileSystem() }, + writeSSH: { _, _ in } + ) + let app = try makeApp(language: language, connectionServices: services) + defer { app.closeAll() } + let identity = Identity(name: "Deploy", username: "deploy") + try app.library.upsert(identity, passwordUpdate: .replace("marketing-fixture-only")) + var host = try #require(app.library.hosts.first(where: { $0.name == "web-01" })) + host.identityID = identity.id + app.library.upsert(host) + let id = try #require(app.open(hostID: host.id, kind: kind)) + let workspace = try #require(app.workspaces.first(where: { $0.id == id })) + for _ in 0..<200 where !workspace.connected { + try await Task.sleep(for: .milliseconds(25)) + } + try #require(workspace.connected) + if kind == .sftp { + #expect(app.renameWorkspace(id, to: text(language, "Production files", "生产文件", "正式環境檔案"))) + try #require(workspace.sftpSession.files.count == 6) + } + let scene = MobileSceneModel(app: app) + scene.selectWorkspace(id) + try await capture(kind == .ssh ? "ssh" : "sftp", language: language) { + MobileRootView(app: app, scene: scene) + } + } + + private func makeTerminal() -> TerminalViewState { let backend = InMemoryTerminalSession(write: { _ in }, resize: { _ in }) let terminal = TerminalViewState(configSource: .none) terminal.configuration = TerminalSurfaceOptions(backend: .inMemory(backend), resizeThrottleMilliseconds: 0) let surface = MobileTerminalSurface() terminal.makePlatformView = { surface.view } + backend.receive(terminalOutput) + return terminal + } + + private var terminalOutput: Data { let prompt = "\u{001B}[36mdeploy@web-01\u{001B}[0m \u{001B}[34m/var/log/nginx\u{001B}[0m %" let lines = [ "Last login: Sun Sep 13 14:20:06 from 10.0.0.12", @@ -141,8 +188,7 @@ struct MobileMarketingScreenshotTests { "10.0.4.31 GET /assets/app 304 2ms", prompt, ] - backend.receive(Data(lines.joined(separator: "\r\n").utf8)) - return terminal + return Data(lines.joined(separator: "\r\n").utf8) } private func makeMetrics(language: String) -> [ServerMetric] { @@ -203,7 +249,7 @@ struct MobileMarketingScreenshotTests { if isPad, let tabs = tabController(in: controller) { // Keep each scene's navigation presentation independent of restored simulator state. - tabs.sidebar.isHidden = name == "metrics" + tabs.sidebar.isHidden = ["metrics", "ssh", "sftp"].contains(name) try await Task.sleep(for: .milliseconds(900)) } @@ -247,7 +293,9 @@ struct MobileMarketingScreenshotTests { let locale = language == "zh-Hans" ? "zh-hans" : language == "zh-Hant" ? "zh-hant" : "en" let ready = control.appending(path: "ready") let ack = control.appending(path: "ack-\(token)") - try "\(name)\t\(device)\t\(locale)\t\(token)\n".write(to: ready, atomically: true, encoding: .utf8) + let captureID = try #require(ProcessInfo.processInfo.environment["TERMIND_MARKETING_CAPTURE_ID"]) + try "\(name)\t\(device)\t\(locale)\t\(token)\t\(captureID)\n".write( + to: ready, atomically: true, encoding: .utf8) for _ in 0..<300 { if FileManager.default.fileExists(atPath: ack.path) { @@ -381,7 +429,7 @@ private struct MarketingMetricsScene: View { } } -private actor MarketingFileSystem: RemoteFileSystem { +private actor MarketingFileSystem: SFTPClientProtocol { private let files: [RemoteFile] = [ RemoteFile( path: "/srv/termind/releases/2026.09/bin", name: "bin", kind: .directory, size: 0, @@ -404,6 +452,9 @@ private actor MarketingFileSystem: RemoteFileSystem { modifiedAt: Date(timeIntervalSince1970: 1_788_942_000), permissions: 0o755, ownerUID: 501, ownerGID: 20), ] + func connect(using _: SSHConnectionConfiguration, progress _: SSHRouteProgressHandler?) async throws {} + func disconnect() async {} + func homeDirectory() async throws -> String { "/srv/termind/releases/2026.09" } func listDirectory(at _: String) async throws -> [RemoteFile] { files } func attributes(at _: String) async throws -> RemoteFile? { nil } func readFile(at _: String, maximumBytes _: Int) async throws -> Data { Data() } @@ -413,4 +464,12 @@ private actor MarketingFileSystem: RemoteFileSystem { func rename(from _: String, to _: String, overwrite _: Bool) async throws {} func removeFile(at _: String) async throws {} func removeDirectory(at _: String) async throws {} + func uploadFile( + from _: URL, to _: String, overwrite _: Bool, cancellation _: TransferCancellationToken, + progress _: @escaping @Sendable (SFTPProgress) -> Void + ) async throws {} + func downloadFile( + from _: String, to _: URL, overwrite _: Bool, cancellation _: TransferCancellationToken, + progress _: @escaping @Sendable (SFTPProgress) -> Void + ) async throws {} } diff --git a/TFMTests/MacMarketingScreenshotTests.swift b/TFMTests/MacMarketingScreenshotTests.swift index 4e2fa72c..9c78f03b 100644 --- a/TFMTests/MacMarketingScreenshotTests.swift +++ b/TFMTests/MacMarketingScreenshotTests.swift @@ -27,6 +27,10 @@ import Testing window.contentView?.layoutSubtreeIfNeeded() try await Task.sleep(for: .seconds(1)) window.contentView?.layoutSubtreeIfNeeded() + if configuration.scene == .hosts { + window.initialFirstResponder = nil + window.makeFirstResponder(nil) + } for _ in 0..<100 { NSRunningApplication.current.activate(options: [.activateAllWindows]) @@ -34,19 +38,18 @@ import Testing window.orderFrontRegardless() window.makeMain() window.makeKey() - if NSApp.isActive, window.isKeyWindow { break } + if NSApp.isActive, window.isKeyWindow { + // Activation can be transient while test hosts launch and exit. + // Require the focused appearance to stay active through rendering. + try await Task.sleep(for: .milliseconds(300)) + if NSApp.isActive, window.isKeyWindow { break } + } try await Task.sleep(for: .milliseconds(50)) } guard NSApp.isActive, window.isKeyWindow else { Issue.record("Marketing window could not become the focused key window") return } - if configuration.scene == .hosts { - window.initialFirstResponder = nil - window.makeFirstResponder(nil) - } - // Allow the active appearance to render before the external capture. - try await Task.sleep(for: .milliseconds(300)) try #require(NSApp.isActive && window.isKeyWindow) try #require(window.backingScaleFactor == 2)