Keep workbook export names and verify native recovery flows
4 files changed,
+74
−19
internal/sheets/native_review_test.go+28 −7native/REVIEW.md+27 −5native/apple/Sources/SheetsKit/SheetFiles.swift+14 −7native/ios/UITests/NativeUITests.swift+5 −0
diff --git a/internal/sheets/native_review_test.go b/internal/sheets/native_review_test.go| index 925f533..4bf21af 100644 |
| --- a/internal/sheets/native_review_test.go |
| +++ b/internal/sheets/native_review_test.go |
| @@ -116,6 +116,12 @@ func TestNativeReview(t *testing.T) { |
| if err := os.Remove(credentialPath); err != nil && !os.IsNotExist(err) { |
| t.Errorf("remove review credential: %v", err) |
| } |
| + staged, _ := filepath.Glob(filepath.Join(strings.TrimSpace(string(current)), "tmp", "oficina-sheets-export-*")) |
| + for _, file := range staged { |
| + if err := os.RemoveAll(file); err != nil { |
| + t.Errorf("remove isolated export artifact: %v", err) |
| + } |
| + } |
| }() |
| defer exec.Command("xcrun", "simctl", "terminate", simulator, bundle).Run() |
| if os.Getenv("NATIVE_REVIEW_UI_TESTS") != "" { |
| @@ -124,6 +130,10 @@ func TestNativeReview(t *testing.T) { |
| defer cancel() |
| command := exec.CommandContext(commandContext, "xcodebuild", "-project", "native/ios/SheetsPhone.xcodeproj", "-scheme", "SheetsReviewPhone", "-destination", "platform=iOS Simulator,id="+simulator, "-parallel-testing-enabled", "NO", "-derivedDataPath", "native/build/review-ios", "CODE_SIGNING_ALLOWED=NO", "-resultBundlePath", resultBundle, "test-without-building") |
| command.Dir = "../.." |
| + only := os.Getenv("NATIVE_REVIEW_UI_ONLY") |
| + if only != "" { |
| + command.Args = append(command.Args, "-only-testing:SheetsPhoneUITests/"+only) |
| + } |
| logPath := filepath.Join(os.TempDir(), "sheets-native-ui-"+simulator+".log") |
| logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0600) |
| if err != nil { |
| @@ -135,12 +145,23 @@ func TestNativeReview(t *testing.T) { |
| if err := command.Run(); err != nil { |
| t.Fatal("Native UI tests failed; see", logPath, err) |
| } |
| - verifyReviewFormula(t, a, credential, seed.ID) |
| - if !refusedSyntheticEdit.Load() || !requestedExport.Load() { |
| - t.Fatal("Native retry and export controls did not reach their isolated server paths") |
| + if only == "" { |
| + verifyReviewFormula(t, a, credential, seed.ID, true, true) |
| + if !refusedSyntheticEdit.Load() || !requestedExport.Load() { |
| + t.Fatal("Native retry and export controls did not reach their isolated server paths") |
| + } |
| + } else if strings.Contains(only, "CreateAndEdit") { |
| + verifyReviewFormula(t, a, credential, seed.ID, true, false) |
| + } else if strings.Contains(only, "FailedCellEdit") { |
| + verifyReviewFormula(t, a, credential, seed.ID, false, true) |
| + if !refusedSyntheticEdit.Load() { |
| + t.Fatal("Retry UI did not exercise the isolated write refusal") |
| + } |
| + } else if strings.Contains(only, "Export") && !requestedExport.Load() { |
| + t.Fatal("Native export control did not reach the isolated server") |
| } |
| current := strings.TrimSpace(string(run("get_app_container", simulator, bundle, "data"))) |
| - staged, err := filepath.Glob(filepath.Join(current, "tmp", "oficina-sheets-export-*.xlsx")) |
| + staged, err := filepath.Glob(filepath.Join(current, "tmp", "oficina-sheets-export-*")) |
| if err != nil || len(staged) != 0 { |
| t.Fatal("Cancelled native export left an unsealed staged workbook") |
| } |
| @@ -164,7 +185,7 @@ func TestNativeReview(t *testing.T) { |
| } |
| for _, test := range manifest { |
| for _, attachment := range test.Attachments { |
| - if strings.Contains(attachment.SuggestedHumanReadableName, "Sheets native formula editing") { |
| + if strings.Contains(attachment.SuggestedHumanReadableName, "Sheets native") { |
| image, err := os.ReadFile(filepath.Join(attachments, filepath.Base(attachment.ExportedFileName))) |
| if err != nil { |
| t.Fatal(err) |
| @@ -222,7 +243,7 @@ func reviewNativeCredential(t *testing.T, handler http.Handler, browser []*http. |
| // Verify the UI's result through a fresh authenticated read, so rendering a |
| // optimistic value without persistence cannot satisfy this fixture. |
| -func verifyReviewFormula(t *testing.T, a *App, credential map[string]string, seedID uint64) { |
| +func verifyReviewFormula(t *testing.T, a *App, credential map[string]string, seedID uint64, requireMain, requireRetry bool) { |
| t.Helper() |
| get := func(path string) *httptest.ResponseRecorder { |
| request := nativeGet(path) |
| @@ -261,7 +282,7 @@ func verifyReviewFormula(t *testing.T, a *App, credential map[string]string, see |
| verifiedMain = true |
| } |
| } |
| - if verifiedMain && verifiedRetry { |
| + if (!requireMain || verifiedMain) && (!requireRetry || verifiedRetry) { |
| return |
| } |
| t.Fatal("Native formula, fill, range paste, formatting and history restore did not persist") |
diff --git a/native/REVIEW.md b/native/REVIEW.md| index 7990066..2673991 100644 |
| --- a/native/REVIEW.md |
| +++ b/native/REVIEW.md |
| @@ -23,12 +23,22 @@ Set `NATIVE_REVIEW_SIMULATOR` to a booted iPhone or iPad simulator UUID, then ru |
| xcrun simctl install "$NATIVE_REVIEW_SIMULATOR" \ |
| native/build/review-ios/Build/Products/Debug-iphonesimulator/SheetsReviewPhone.app |
| NATIVE_REVIEW_UI_TESTS=1 NATIVE_REVIEW_OUTPUT=/tmp/sheets-native-review.png \ |
| - go test ./internal/sheets -run '^TestNativeReview$' -count=1 -timeout 5m |
| + go test ./internal/sheets -run '^TestNativeReview$' -count=1 -timeout 8m |
| ``` |
| -The UI test creates a sheet, enters `=2+3`, checks `5`, selects the cell to |
| -read its formula, changes it to `=7*6`, and checks `42`. A separate authenticated |
| -server read verifies that the final raw formula and computed value persisted. |
| +The UI cases create a sheet, enter `=2+3`, check `5`, select the cell to read its |
| +formula, change it to `=7*6`, and check `42`. They then exercise fill down, |
| +range copy/paste, number formatting, own input undo and version-history restore. |
| +A separate case injects one HTTP 503 for the exact synthetic formula `=6*7`, |
| +checks that its draft remains visible and protected, and retries it. Export |
| +opens the real system destination picker and cancels; the fixture verifies |
| +the export request reached the server and no staged XLSX remains afterward. |
| +It does not test saving a file to an external destination. |
| +A fresh authenticated server read verifies the resulting raw formulas and |
| +computed values. To isolate a case, set `NATIVE_REVIEW_UI_ONLY` to |
| +`NativeUITests/testCreateAndEditFormula`, |
| +`NativeUITests/testFailedCellEditCanBeRetriedWithoutLosingDraft`, or |
| +`NativeUITests/testExportWorkbookOpensNativeDestinationAndCancels`. |
| The fixture exports the test's screenshot attachment to `NATIVE_REVIEW_OUTPUT`. |
| Its xcodebuild log is stored under the operating system temporary directory |
| as `sheets-native-ui-<simulator UUID>.log`. |
| @@ -47,7 +57,19 @@ checks access and does not request it automatically. On 2026-09-13, the review |
| executable compiled and authenticated, but this environment lacked capture |
| access; no Mac screenshot was accepted as verification. |
| -On 2026-09-13 the UI flow and server persistence check passed on iPhone 17 Pro |
| +On 2026-09-13 the initial formula UI flow and server persistence check passed on iPhone 17 Pro |
| and iPad Pro 13-inch (M5), iOS 26.5. The same test first exposed compact-screen |
| navigation and cell-selection failures before their fixes. This exercises the |
| named flow, not every feature in the parity inventory. |
| +The expanded three-case iPad UI suite passed after exposing a blank history |
| +panel and an export filename crash. Its combined Xcode result finalization |
| +then timed out; separate fixture runs distinguish UI assertions from complete |
| +server-persistence and cleanup verification. The isolated export case passed |
| +including its server and temporary-file checks. |
| +The isolated expanded editing case subsequently passed in 192.565 seconds, |
| +including its fresh 2×2 formula/format/history checks; the isolated refused-write |
| +retry passed in 45.066 seconds, including verification that `=6*7` persisted. |
| +The final export stores the workbook under its title inside a private unique |
| +directory. Its isolated test passed in 58.071 seconds after a simulator restart, |
| +including removal of that directory after cancellation. The screenshot was |
| +visually checked: the system picker displays “Native review workbook”. |
diff --git a/native/apple/Sources/SheetsKit/SheetFiles.swift b/native/apple/Sources/SheetsKit/SheetFiles.swift| index 7196fbb..da8ef7b 100644 |
| --- a/native/apple/Sources/SheetsKit/SheetFiles.swift |
| +++ b/native/apple/Sources/SheetsKit/SheetFiles.swift |
| @@ -7,12 +7,15 @@ struct WorkbookExport: Transferable { |
| let filename: String |
| init(url: URL, title: String) { |
| self.url = url |
| + filename = Self.filename(for: title) |
| + } |
| + static func filename(for title: String) -> String { |
| var name = title.components(separatedBy: .controlCharacters).joined() |
| .replacingOccurrences(of: "/", with: "-").replacingOccurrences(of: ":", with: "-") |
| .trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: "."))) |
| while name.utf8.count > 200 { name.removeLast() } |
| if name.isEmpty { name = "Workbook" } |
| - filename = name.lowercased().hasSuffix(".xlsx") ? name : name + ".xlsx" |
| + return name.lowercased().hasSuffix(".xlsx") ? name : name + ".xlsx" |
| } |
| static var transferRepresentation: some TransferRepresentation { |
| FileRepresentation(exportedContentType: UTType(filenameExtension: "xlsx") ?? .data) { |
| @@ -75,7 +78,7 @@ struct SheetFiles: View { |
| } |
| } |
| private func cleanupExport() { |
| - if let workbook { try? FileManager.default.removeItem(at: workbook.url) } |
| + if let workbook { try? FileManager.default.removeItem(at: workbook.url.deletingLastPathComponent()) } |
| workbook = nil |
| } |
| private func export() async { |
| @@ -85,18 +88,22 @@ struct SheetFiles: View { |
| } |
| working = true |
| defer { working = false } |
| - let destination = FileManager.default.temporaryDirectory |
| - .appendingPathComponent("oficina-sheets-export-" + UUID().uuidString + ".xlsx") |
| + let directory = FileManager.default.temporaryDirectory |
| + .appendingPathComponent("oficina-sheets-export-" + UUID().uuidString, isDirectory: true) |
| + let title = model.page?.Sheet.Title ?? "Workbook" |
| + let destination = directory.appendingPathComponent(WorkbookExport.filename(for: title)) |
| do { |
| + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false, |
| + attributes: [.posixPermissions: 0o700]) |
| try await model.client.download(model.base + "/export.xlsx", to: destination) |
| guard active else { |
| - try? FileManager.default.removeItem(at: destination) |
| + try? FileManager.default.removeItem(at: directory) |
| return |
| } |
| - workbook = WorkbookExport(url: destination, title: model.page?.Sheet.Title ?? "Workbook") |
| + workbook = WorkbookExport(url: destination, title: title) |
| exporter = true |
| } catch { |
| - try? FileManager.default.removeItem(at: destination) |
| + try? FileManager.default.removeItem(at: directory) |
| progress = error.localizedDescription |
| } |
| } |
diff --git a/native/ios/UITests/NativeUITests.swift b/native/ios/UITests/NativeUITests.swift| index 02ef41b..6235cd3 100644 |
| --- a/native/ios/UITests/NativeUITests.swift |
| +++ b/native/ios/UITests/NativeUITests.swift |
| @@ -5,6 +5,7 @@ import XCTest |
| continueAfterFailure = false |
| let app = XCUIApplication() |
| app.launch() |
| + defer { app.terminate() } |
| let create = app.buttons["New sheet"] |
| XCTAssertTrue(create.waitForExistence(timeout: 15)) |
| create.tap() |
| @@ -76,6 +77,7 @@ import XCTest |
| continueAfterFailure = false |
| let app = XCUIApplication() |
| app.launch() |
| + defer { app.terminate() } |
| XCTAssertTrue(app.buttons["New sheet"].waitForExistence(timeout: 15)) |
| app.buttons["New sheet"].tap() |
| let formula = app.textFields["Cell contents or formula"] |
| @@ -93,12 +95,15 @@ import XCTest |
| retry.tap() |
| XCTAssertTrue(app.buttons["A1, 42"].waitForExistence(timeout: 10)) |
| XCTAssertEqual(XCTWaiter.wait(for: [XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: retry)], timeout: 10), .completed) |
| + let screenshot = XCTAttachment(screenshot: app.screenshot()) |
| + screenshot.name = "Sheets native retried edit"; screenshot.lifetime = .keepAlways; add(screenshot) |
| } |
| func testExportWorkbookOpensNativeDestinationAndCancels() { |
| continueAfterFailure = false |
| let app = XCUIApplication() |
| app.launch() |
| + defer { app.terminate() } |
| let workbook = app.buttons["Native review workbook"] |
| XCTAssertTrue(workbook.waitForExistence(timeout: 15)) |
| workbook.tap() |