Skip to content
Go back

Teaching Codex to Test a Voice-First Calendar

Suggest Changes

Teaching Codex to test a voice-first calendar

AI-generated entry. See What & Why for context.


I have been working on version 2.0 of KIN, a voice-first shared family calendar.

The core interaction is deliberately simple: hold to talk, say something like “add soccer practice tomorrow at 5”, and KIN turns that into a calendar event for the family.

That is a nice interaction for humans.

It is an annoying interaction for tests.

Normal UI automation is good at tapping buttons and typing text. It is much worse at pretending to be a person who presses and holds a microphone button, speaks into the iOS Simulator, waits for transcription, waits for an AI-backed calendar operation, and then verifies that the right event was actually created.

The version that finally worked combined a few pieces:

Once those pieces were in place, I could run a local end-to-end test that creates a real event in KIN by injecting audio into the iOS Simulator.

The problem

KIN is not a form with a microphone icon attached to it.

The voice path is the product path:

  1. The user long-presses the voice bar.
  2. The app starts recording.
  3. The audio goes through transcription.
  4. The transcript goes to the calendar AI backend.
  5. The backend mutates calendar state.
  6. The app shows the result.

If I mock the transcript, I am not testing the microphone path.

If I mock the backend, I am not testing the calendar agent.

If I only test the backend, I am not testing whether the iOS app actually records and sends audio correctly.

What I wanted was a local test that exercised the same path a user does, without requiring me to sit there and repeat the same sentence into my laptop ten times.

Why this moved out of Maestro

I still use Maestro for ordinary UI smoke flows. It is good for that.

Voice input has one awkward constraint, though: the press duration matters.

KIN starts recording while the user holds the voice bar and stops when the press ends. If the utterance is three seconds long, the test needs to hold for a little more than three seconds. If the utterance is six seconds long, the test needs a different hold duration.

For this particular job, Maestro’s long press was too fixed.

XCUITest gives me the one API I needed:

press(forDuration:)

That one call is why the voice tests moved into XCUITest. The test can hold the real voice_assistant_bar for exactly as long as the audio fixture needs.

The audio trick

The key was to stop treating the simulator as a magical testing object and start treating it as another Mac app with an audio input menu.

Loopback creates a virtual audio device on macOS. I created a device named Loopback Audio with a pass-through source. Then the runner does two things:

Now anything I play from the Mac can also appear as microphone input.

The simulator still has to use that microphone. FlowDeck opens Simulator, and an AppleScript helper selects the same device from:

Simulator -> I/O -> Audio Input -> Loopback Audio

At that point, the path is:

/usr/bin/afplay -> macOS output -> Loopback Audio -> Simulator mic -> KIN

For generated tests, the audio file starts as text:

/usr/bin/say -o utterance.aiff -- "Add loopback single amber river tomorrow at 3 PM."

That part is surprisingly easy. macOS ships with the built-in say utility, and it can write spoken audio directly from a string at test time. The runner does not need a library of pre-recorded fixtures for every happy-path command. It can generate the phrase, measure the resulting AIFF file, route it through Loopback, and use a unique marker phrase for the database assertion.

For more realistic tests later, the same harness can use a recorded fixture:

KIN_VOICE_AUDIO_FIXTURE=/absolute/path/to/sample.wav

The app does not know the difference. From KIN’s point of view, someone spoke into the microphone.

The local path

Five-step diagram showing the local KIN voice test path. The test generates spoken audio with say, runs KIN through FlowDeck, holds the voice UI with XCUITest, routes audio through Loopback into the simulator microphone, then lets KIN, the backend, and Supabase process the request normally.

Timing the long press

This is the part that made the test feel real instead of lucky.

The runner measures the audio duration:

/usr/bin/afinfo utterance.aiff

Then it computes:

holdDuration = audioDuration + 1.25 seconds

The extra time gives the app room to start recording and finish ingesting the last bit of audio.

The XCUITest does not play audio itself. Instead, the runner starts a tiny local helper server with three endpoints:

XCUITest loads /config, presses the voice bar, calls /play, and keeps holding until holdDuration has elapsed.

The helper waits briefly before playing audio:

playbackDelay = 0.45 seconds

That delay matters. It means recording has already started before afplay begins sending the fixture through Loopback.

The rough shape is:

Runner      -> FlowDeck: run app with --local-backend
Runner      -> Helper: start /health /config /play
Runner      -> FlowDeck: run only the voice XCUITest
XCUITest    -> KIN: press voice_assistant_bar
XCUITest    -> Helper: GET /play?delay=0.45
Helper      -> Loopback: afplay utterance.aiff
Loopback    -> Simulator: virtual microphone input
Simulator   -> KIN: recorded audio
KIN         -> Backend: transcribe + calendar inference
Backend     -> KIN: acknowledgement + mutation
Runner      -> Backend: assert local database state

What Codex actually did

The interesting part was not that Codex wrote a test file.

The interesting part was that Codex could operate the whole loop:

This is where agentic coding starts to feel qualitatively different from code completion.

The work was not one isolated patch. It was a loop across app code, test code, simulator state, local services, logs, and the database.

Why the backend assertion matters

For voice and LLM flows, UI text is a weak primary assertion.

The app might say:

Added.

Or it might say:

I added that to your calendar.

Both are fine.

But if the test utterance contains a unique marker phrase, the database gives a cleaner answer.

For example:

Add loopback single amber river willow tomorrow at 3 PM.

The runner snapshots local calendar state before the test. After the voice flow, it polls local Supabase and checks that a new event containing this marker exists for the test family:

loopback single amber river willow

That became the split:

This also made multi-event tests possible. One scenario says:

Add a dentist appointment tomorrow at 2 PM and soccer practice tomorrow at 5 PM.

The test does not need the UI to phrase the response in a specific way. It checks that the local backend created both events.

The first five scenarios

The first committed suite covers the paths I care about most:

  1. Create one event by voice.
  2. Create multiple events in one utterance.
  3. Create a recurring event.
  4. Ask a non-mutating calendar question.
  5. Inject silence and verify no mutation.

The last two are important.

A voice assistant should not only do the right thing when the user gives a clean command. It should also avoid doing the wrong thing when the input is a question, silence, or garbage.

Why this stays local for now

This is intentionally not a CI test right now.

The harness depends on:

That is exactly the kind of test I want locally before shipping voice changes, but not something I want to debug on a generic hosted runner.

The root command is:

pnpm test:e2e:ios:v2:voice:local

It leaves the normal Maestro smoke flows alone. That matters because not every UI test needs to pay the cost of audio routing and local AI inference.

What changed in my mental model

Before this, I thought of voice UI testing as either mocked or manual.

Now I think there is a useful third option:

Automate the environment around the app, then let the app behave normally.

Loopback handles the microphone problem.

FlowDeck handles the simulator problem.

XCUITest handles the exact gesture timing.

Codex handles the tedious glue across all of it.

That combination is what made the test practical.

It is still local. It is still a little mechanical. It still depends on the machine being configured correctly.

But it proves the important thing: KIN can create a real calendar event from audio injected into the simulator.

For a voice-first product, that is the test I actually wanted.

If you are building for family logistics and want a calendar that lets you talk instead of tap through forms, try KIN Calendar.


Suggest Changes
Share this post on:

Next Post
Setting up Google sign in for Chrome extensions with Supabase