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
15 changes: 15 additions & 0 deletions .github/workflows/e2e-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ jobs:
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
run: bash scripts/e2e-app.sh --no-build --record-only

# Re-import lane: chains a record-only meeting with a POST
# /action/enqueueFile that feeds the just-recorded WAV back into the
# "Open from Recording" pipeline. Asserts the resulting transcript
# contains the fixture's spoken keyword, which catches three failure
# classes the lanes above miss in isolation:
# - Recorder writes a malformed WAV (record-only would still pass).
# - AudioMixer.loadAudioAsFloat32 3-tier fallback regresses
# (only fixture-tested today, never live).
# - Live capture is silent or garbled (transcript lane uses the
# fixture WAV directly, doesn't exercise the recorder output).
- name: Run live-recording E2E driver (re-import recorded WAV)
env:
DEVELOPER_ID: ${{ secrets.DEVELOPER_ID }}
run: bash scripts/e2e-app.sh --no-build --reimport-recorded

# Best-effort: capture the simulator's stdout for post-mortem if
# the driver failed before tearing it down.
- name: Upload simulator log
Expand Down
10 changes: 10 additions & 0 deletions app/MeetingTranscriber/Sources/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,10 +172,20 @@ final class AppState {
}
}
}
// RPC counterpart to the NSOpenPanel "Open from Recording" flow.
// Validates the file exists (RPC layer returns 400 on `false`),
// then routes through the same `enqueueFiles` entry point the
// menu uses, so the import code path is identical.
let enqueueFile: (URL) -> Bool = { [weak self] url in
guard let self, FileManager.default.fileExists(atPath: url.path) else { return false }
Task { @MainActor in self.enqueueFiles([url]) }
return true
}
let server = DebugRPCServer(
snapshot: snapshot,
speakerActions: makeSpeakerDBActions(),
skipNaming: skipNaming,
enqueueFile: enqueueFile,
)
server.start()
debugRPCServer = server
Expand Down
24 changes: 24 additions & 0 deletions app/MeetingTranscriber/Sources/DebugRPCServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@
private let snapshot: () -> RPCStateSnapshot
private let speakerActions: SpeakerDBActions
private let skipNaming: () -> Void
/// Enqueues a previously-recorded file into the pipeline — same path
/// `processAudioFiles` (NSOpenPanel) takes. Returns `false` if the
/// caller's path is missing or doesn't exist on disk; the RPC layer
/// translates that to 400.
private let enqueueFile: (URL) -> Bool
private let expectedAuth: String
private var listener: NWListener?
/// OS-assigned port once the listener is `.ready`. Useful for tests
Expand All @@ -90,12 +95,14 @@
snapshot: @escaping () -> RPCStateSnapshot,
speakerActions: SpeakerDBActions = .noop,
skipNaming: @escaping () -> Void = {},
enqueueFile: @escaping (URL) -> Bool = { _ in false },
) {
self.port = NWEndpoint.Port(rawValue: port) ?? NWEndpoint.Port.any
self.expectedAuth = "Bearer \(token)"
self.snapshot = snapshot
self.speakerActions = speakerActions
self.skipNaming = skipNaming
self.enqueueFile = enqueueFile
}

/// Generate a 32-byte hex token, persist atomically with mode 0600, return it.
Expand Down Expand Up @@ -244,6 +251,19 @@
skipNaming()
return HTTPResponse.ok(body: Data("ok\n".utf8), contentType: "text/plain")

case ("POST", "/action/enqueueFile"):
// Enqueues a previously-recorded audio file into the pipeline
// — the same code path NSOpenPanel hits via "Open from
// Recording". Used by `scripts/e2e-app.sh` to chain a
// record-only run with a re-import + transcript assertion.
// 400 on missing/empty path or undecodable JSON.
guard let p = try? JSONDecoder().decode(EnqueueFilePayload.self, from: request.body),
!p.path.isEmpty
else { return HTTPResponse.badRequest() }
let url = URL(fileURLWithPath: p.path)
guard enqueueFile(url) else { return HTTPResponse.badRequest() }
return HTTPResponse.ok(body: Data("ok\n".utf8), contentType: "text/plain")

case ("POST", "/action/renameSpeaker"),
("POST", "/action/deleteSpeaker"),
("POST", "/action/mergeSpeakers"),
Expand Down Expand Up @@ -318,6 +338,10 @@
let name: String
}

private struct EnqueueFilePayload: Decodable {
let path: String
}

/// Map the action outcome to an HTTP response. `notFound` → 404,
/// `invalid` → 400, everything else → 200 with the outcome string in the body.
private static func respond(to outcome: SpeakerActionOutcome) -> HTTPResponse {
Expand Down
69 changes: 67 additions & 2 deletions app/MeetingTranscriber/Tests/DebugRPCServerIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,16 @@

// MARK: - Setup

private func startServer(snapshot: RPCStateSnapshot = .empty) async throws -> URL {
let server = DebugRPCServer(port: 0, token: Self.testToken) { snapshot }
private func startServer(
snapshot: RPCStateSnapshot = .empty,
enqueueFile: @escaping (URL) -> Bool = { _ in false },
) async throws -> URL {
let server = DebugRPCServer(
port: 0,
token: Self.testToken,
snapshot: { snapshot },
enqueueFile: enqueueFile,
)
self.server = server
server.start()
// Wait for the listener's stateUpdateHandler to populate boundPort.
Expand Down Expand Up @@ -129,6 +137,63 @@
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 503)
}

// MARK: - /action/enqueueFile

func testEnqueueFileMissingPathReturns400() async throws {
let base = try await startServer()
var req = request("POST", base.appendingPathComponent("action/enqueueFile"), headers: authHeader)
req.httpBody = Data("{}".utf8)
let (_, response) = try await URLSession.shared.upload(for: req, from: XCTUnwrap(req.httpBody))
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 400)
}

func testEnqueueFileNonexistentPathReturns400() async throws {
// Closure returns false → RPC layer translates to 400.
let base = try await startServer { _ in false }
var req = request("POST", base.appendingPathComponent("action/enqueueFile"), headers: authHeader)
req.httpBody = Data(#"{"path":"/tmp/definitely-does-not-exist-\#(UUID().uuidString).wav"}"#.utf8)
let (_, response) = try await URLSession.shared.upload(for: req, from: XCTUnwrap(req.httpBody))
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 400)
}

func testEnqueueFileValidPathReturns200AndInvokesClosure() async throws {
// Temp file the closure can `fileExists`-check if it chooses to.
let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("rpc-enqueue-\(UUID().uuidString).wav")
FileManager.default.createFile(atPath: tmp.path, contents: Data("RIFF".utf8))
defer { try? FileManager.default.removeItem(at: tmp) }

// Use an actor-isolated box so we can observe from the test body
// without sharing mutable state across the closure boundary.
actor InvocationBox {
var receivedPath: String?
func record(_ p: String) {
receivedPath = p
}
}
let box = InvocationBox()
let base = try await startServer { url in
Task { await box.record(url.path) }
return true
}

var req = request("POST", base.appendingPathComponent("action/enqueueFile"), headers: authHeader)
req.httpBody = Data(#"{"path":"\#(tmp.path)"}"#.utf8)
let (_, response) = try await URLSession.shared.upload(for: req, from: XCTUnwrap(req.httpBody))
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)

// Closure dispatches into a Task — poll instead of a fixed sleep
// so a slow lane (sanitizers ~7.5 min) doesn't flake on a tight
// 50 ms budget. Worst-case wall is still 500 ms.
var received: String?
for _ in 0 ..< 20 {
received = await box.receivedPath
if received != nil { break }
try await Task.sleep(for: .milliseconds(25))
}
XCTAssertEqual(received, tmp.path)
}

// MARK: - M6: Host header allowlist (raw-socket)

/// `URLRequest.setValue(_:forHTTPHeaderField: "Host")` is silently
Expand Down
Loading
Loading