Add FakeImageSources and simulation functions for testing

This change introduces a dedicated provider interface for creating
ImageSource(s) in CameraPipe, which allows for CameraGraphs to:

- Simulate fake images during tests
- Simulate fake images for all requests during tests
- Check that all simulated images have been closed
- Check that all fake ImageReaders and ImageSources have been closed
- Inspect all CameraGraphs that have been created via a
  CameraPipeSimulator interface.

This change also causes CameraGraphSimulator and CameraPipeSimulator
to extend their respective interfaces by delegation, which ensures
that the underlying behavior remains in sync with the real CameraPipe
and CameraGraph implementations.

Test: ./gradlew\
      :camera:camera-camera2-pipe:testDebugUnitTest\
      :camera:camera-camera2-pipe-testing:testDebugUnitTest\
      :camera:camera-camera2-pipe-integration:testDebugUnitTest

Change-Id: Ide6104eb96467aaecaa5023eb942eb605f65a11e
diff --git a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/RequestProcessorAdapterTest.kt b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/RequestProcessorAdapterTest.kt
index 80f3758..a933a0a 100644
--- a/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/RequestProcessorAdapterTest.kt
+++ b/camera/camera-camera2-pipe-integration/src/test/java/androidx/camera/camera2/pipe/integration/adapter/RequestProcessorAdapterTest.kt
@@ -111,7 +111,7 @@
     }
 
     private fun initialize(scope: TestScope) {
-        cameraGraphSimulator =
+        val simulator =
             CameraGraphSimulator.create(
                     scope,
                     context,
@@ -119,24 +119,24 @@
                     graphConfig,
                 )
                 .also {
-                    it.cameraGraph.start()
+                    it.start()
                     it.simulateCameraStarted()
-                    it.simulateFakeSurfaceConfiguration()
+                    it.initializeSurfaces()
                 }
-        val cameraGraph = cameraGraphSimulator!!.cameraGraph
+        cameraGraphSimulator = simulator
         val surfaceToStreamMap =
             buildMap<DeferrableSurface, StreamId> {
                 put(
                     previewProcessorSurface,
-                    checkNotNull(cameraGraph.streams[previewStreamConfig]).id
+                    checkNotNull(simulator.streams[previewStreamConfig]).id
                 )
                 put(
                     imageCaptureProcessorSurface,
-                    checkNotNull(cameraGraph.streams[imageCaptureStreamConfig]).id
+                    checkNotNull(simulator.streams[imageCaptureStreamConfig]).id
                 )
             }
         val useCaseGraphConfig =
-            UseCaseGraphConfig(cameraGraph, surfaceToStreamMap, CameraStateAdapter())
+            UseCaseGraphConfig(simulator, surfaceToStreamMap, CameraStateAdapter())
 
         requestProcessorAdapter =
             RequestProcessorAdapter(
@@ -172,9 +172,7 @@
         val request = frame.request
         assertThat(request.streams.size).isEqualTo(1)
         assertThat(request.streams.first())
-            .isEqualTo(
-                checkNotNull(cameraGraphSimulator!!.cameraGraph.streams[previewStreamConfig]).id
-            )
+            .isEqualTo(checkNotNull(cameraGraphSimulator!!.streams[previewStreamConfig]).id)
 
         verify(callback, times(1)).onCaptureStarted(eq(requestToSet), any(), any())
 
@@ -215,10 +213,7 @@
         val request = frame.request
         assertThat(request.streams.size).isEqualTo(1)
         assertThat(request.streams.first())
-            .isEqualTo(
-                checkNotNull(cameraGraphSimulator!!.cameraGraph.streams[imageCaptureStreamConfig])
-                    .id
-            )
+            .isEqualTo(checkNotNull(cameraGraphSimulator!!.streams[imageCaptureStreamConfig]).id)
         assertThat(request.parameters[CaptureRequest.CONTROL_AE_MODE])
             .isEqualTo(CONTROL_AE_MODE_OFF)
 
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulator.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulator.kt
index 7e4fea2..7aa12ed 100644
--- a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulator.kt
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulator.kt
@@ -18,22 +18,23 @@
 
 import android.content.Context
 import android.hardware.camera2.CaptureResult
+import android.media.ImageReader
 import androidx.camera.camera2.pipe.CameraGraph
 import androidx.camera.camera2.pipe.CameraId
 import androidx.camera.camera2.pipe.CameraMetadata
 import androidx.camera.camera2.pipe.CameraPipe
-import androidx.camera.camera2.pipe.CameraPipe.CameraBackendConfig
 import androidx.camera.camera2.pipe.CameraTimestamp
 import androidx.camera.camera2.pipe.CaptureSequences.invokeOnRequest
 import androidx.camera.camera2.pipe.FrameMetadata
 import androidx.camera.camera2.pipe.FrameNumber
 import androidx.camera.camera2.pipe.GraphState.GraphStateError
 import androidx.camera.camera2.pipe.Metadata
+import androidx.camera.camera2.pipe.OutputId
 import androidx.camera.camera2.pipe.Request
 import androidx.camera.camera2.pipe.RequestFailure
 import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.media.ImageSource
 import kotlinx.atomicfu.atomic
-import kotlinx.coroutines.test.StandardTestDispatcher
 import kotlinx.coroutines.test.TestScope
 import kotlinx.coroutines.withTimeout
 
@@ -48,19 +49,25 @@
  * The simulator does not make (many) assumptions about how the simulator will be used, and for this
  * reason it does not automatically put the underlying graph into a "started" state. In most cases,
  * the test will need start the [CameraGraph], [simulateCameraStarted], and either configure
- * surfaces for the [CameraGraph] or call [simulateFakeSurfaceConfiguration] to put the graph into a
- * state where it is able to send and simulate interactions with the camera. This mirrors the normal
- * lifecycle of a [CameraGraph]. Tests using CameraGraphSimulators should also close them after
- * they've completed their use of the simulator.
+ * surfaces for the [CameraGraph] or call [initializeSurfaces] to put the graph into a state where
+ * it is able to send and simulate interactions with the camera. This mirrors the normal lifecycle
+ * of a [CameraGraph]. Tests using CameraGraphSimulators should also close them after they've
+ * completed their use of the simulator.
  */
 class CameraGraphSimulator
-private constructor(
-    val context: Context,
-    val cameraMetadata: CameraMetadata,
-    val graphConfig: CameraGraph.Config,
-    val cameraGraph: CameraGraph,
-    private val cameraController: CameraControllerSimulator
-) : AutoCloseable {
+internal constructor(
+    private val cameraMetadata: CameraMetadata,
+    private val cameraController: CameraControllerSimulator,
+    private val fakeImageReaders: FakeImageReaders,
+    private val fakeImageSources: FakeImageSources,
+    private val realCameraGraph: CameraGraph,
+    val config: CameraGraph.Config,
+) : CameraGraph by realCameraGraph, AutoCloseable {
+
+    @Deprecated("CameraGraphSimulator directly implements CameraGraph")
+    val cameraGraph: CameraGraph
+        get() = this
+
     companion object {
         /**
          * Create a CameraGraphSimulator using the current [TestScope] provided by a Kotlin
@@ -70,46 +77,21 @@
          * interactions.
          */
         fun create(
-            scope: TestScope,
-            context: Context,
+            testScope: TestScope,
+            testContext: Context,
             cameraMetadata: CameraMetadata,
             graphConfig: CameraGraph.Config
         ): CameraGraphSimulator {
-            val fakeCameraBackend =
-                FakeCameraBackend(fakeCameras = mapOf(cameraMetadata.camera to cameraMetadata))
-            val cameraPipe =
-                CameraPipe(
-                    CameraPipe.Config(
-                        context,
-                        cameraBackendConfig =
-                            CameraBackendConfig(internalBackend = fakeCameraBackend),
-                        threadConfig =
-                            CameraPipe.ThreadConfig(
-                                testOnlyDispatcher = StandardTestDispatcher(scope.testScheduler),
-                                testOnlyScope = scope,
-                            )
-                    )
-                )
-            val cameraGraph = cameraPipe.create(graphConfig)
-            val cameraController =
-                checkNotNull(fakeCameraBackend.cameraControllers.lastOrNull()) {
-                    "Expected cameraPipe.create to create a CameraController instance from " +
-                        "$fakeCameraBackend as part of its initialization."
-                }
-            return CameraGraphSimulator(
-                context,
-                cameraMetadata,
-                graphConfig,
-                cameraGraph,
-                cameraController
-            )
+            val cameraPipeSimulator =
+                CameraPipeSimulator.create(testScope, testContext, listOf(cameraMetadata))
+            return cameraPipeSimulator.createCameraGraphSimulator(graphConfig)
         }
     }
 
     init {
-        check(graphConfig.camera == cameraMetadata.camera) {
+        check(config.camera == cameraMetadata.camera) {
             "CameraGraphSimulator must be creating with a camera id that matches the provided " +
-                "cameraMetadata! Received ${graphConfig.camera}, but expected " +
+                "cameraMetadata! Received ${config.camera}, but expected " +
                 "${cameraMetadata.camera}"
         }
     }
@@ -121,9 +103,13 @@
     private val pendingFrameQueue = mutableListOf<FrameSimulator>()
     private val fakeSurfaces = FakeSurfaces()
 
+    /** Return true if this [CameraGraphSimulator] has been closed. */
+    val isClosed: Boolean
+        get() = closed.value
+
     override fun close() {
         if (closed.compareAndSet(expect = false, update = true)) {
-            cameraGraph.close()
+            realCameraGraph.close()
             fakeSurfaces.close()
         }
     }
@@ -152,15 +138,30 @@
      * Configure all streams in the CameraGraph with fake surfaces that match the size of the first
      * output stream.
      */
-    fun simulateFakeSurfaceConfiguration() {
+    fun initializeSurfaces() {
         check(!closed.value) {
             "Cannot call simulateFakeSurfaceConfiguration on $this after close."
         }
-        for (stream in cameraGraph.streams.streams) {
-            // Pick an output -- most will only have one.
-            val output = stream.outputs.first()
-            val surface = fakeSurfaces.createFakeSurface(output.size)
-            cameraGraph.setSurface(stream.id, surface)
+        for (stream in streams.streams) {
+            val imageSource = fakeImageSources[stream.id]
+            if (imageSource != null) {
+                println("Using FakeImageSource ${imageSource.surface} for ${stream.id}")
+                continue
+            }
+
+            val imageReader = fakeImageReaders[stream.id]
+            if (imageReader != null) {
+                println("Using FakeImageReader ${imageReader.surface} for ${stream.id}")
+                realCameraGraph.setSurface(stream.id, imageReader.surface)
+                continue
+            }
+
+            // Pick the smallest output (This matches the behavior of MultiResolutionImageReader)
+            val minOutput = stream.outputs.minBy { it.size.width * it.size.height }
+            val surface = fakeSurfaces.createFakeSurface(minOutput.size)
+
+            println("Using Fake $surface for ${stream.id}")
+            realCameraGraph.setSurface(stream.id, surface)
         }
     }
 
@@ -193,6 +194,69 @@
         return pendingFrameQueue.removeFirst()
     }
 
+    /** Utility function to simulate the production of a [FakeImage]s for one or more streams. */
+    fun simulateImage(
+        streamId: StreamId,
+        imageTimestamp: Long,
+        outputId: OutputId? = null,
+    ) {
+        check(simulateImageInternal(streamId, outputId, imageTimestamp)) {
+            "Failed to simulate image for $streamId on $this!"
+        }
+    }
+
+    /**
+     * Utility function to simulate the production of [FakeImage]s for all outputs on a specific
+     * [request]. Use [simulateImage] to directly control simulation of individual outputs.
+     * [physicalCameraId] should be used to select the correct output id when simulating images from
+     * multi-resolution [ImageReader]s and [ImageSource]s
+     */
+    fun simulateImages(request: Request, imageTimestamp: Long, physicalCameraId: CameraId? = null) {
+        var imageSimulated = false
+        for (streamId in request.streams) {
+            val outputId =
+                if (physicalCameraId == null) {
+                    streams.outputs.single().id
+                } else {
+                    streams[streamId]?.outputs?.find { it.camera == physicalCameraId }?.id
+                }
+            val success = simulateImageInternal(streamId, outputId, imageTimestamp)
+            imageSimulated = imageSimulated || success
+        }
+
+        check(imageSimulated) {
+            "Failed to simulate images for $request!" +
+                "No matching FakeImageReaders or FakeImageSources were found."
+        }
+    }
+
+    private fun simulateImageInternal(
+        streamId: StreamId,
+        outputId: OutputId?,
+        imageTimestamp: Long
+    ): Boolean {
+        val stream = streams[streamId]
+        checkNotNull(stream) { "Cannot simulate an image for invalid $streamId on $this!" }
+        // Prefer to simulate images directly on the imageReader if possible, and then
+        // defer to the imageSource if an imageReader does not exist.
+        val imageReader = fakeImageReaders[streamId]
+        if (imageReader != null) {
+            imageReader.simulateImage(imageTimestamp = imageTimestamp, outputId = outputId)
+            return true
+        } else {
+            val fakeImageSource = fakeImageSources[streamId]
+            if (fakeImageSource != null) {
+                fakeImageSource.simulateImage(timestamp = imageTimestamp, outputId = outputId)
+                return true
+            }
+        }
+        return false
+    }
+
+    override fun toString(): String {
+        return "CameraGraphSimulator($realCameraGraph)"
+    }
+
     /**
      * A [FrameSimulator] allows a test to synchronously invoke callbacks. A single request can
      * generate multiple captures (eg, if used as a repeating request). A [FrameSimulator] allows a
@@ -299,6 +363,32 @@
             requestSequence.invokeOnRequest(requestMetadata) { it.onAborted(request) }
         }
 
+        fun simulateImage(
+            streamId: StreamId,
+            imageTimestamp: Long? = null,
+            outputId: OutputId? = null,
+        ) {
+            val timestamp = imageTimestamp ?: timestampNanos
+            checkNotNull(timestamp) {
+                "Cannot simulate an image without a timestamp! Provide an " +
+                    "imageTimestamp or call simulateStarted before simulateImage."
+            }
+            [email protected](streamId, timestamp, outputId)
+        }
+
+        /**
+         * Utility function to simulate the production of [FakeImage]s for all outputs on a Frame.
+         * Use [simulateImage] to directly control simulation of each individual image.
+         */
+        fun simulateImages(imageTimestamp: Long? = null, physicalCameraId: CameraId? = null) {
+            val timestamp = imageTimestamp ?: timestampNanos
+            checkNotNull(timestamp) {
+                "Cannot simulate an image without a timestamp! Provide an " +
+                    "imageTimestamp or call simulateStarted before simulateImage."
+            }
+            [email protected](request, timestamp, physicalCameraId)
+        }
+
         private fun createFakePhysicalMetadata(
             physicalResultMetadata: Map<CameraId, Map<CaptureResult.Key<*>, Any?>>
         ): Map<CameraId, FrameMetadata> {
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraPipeSimulator.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraPipeSimulator.kt
new file mode 100644
index 0000000..89c2da0
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/CameraPipeSimulator.kt
@@ -0,0 +1,198 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import android.content.Context
+import androidx.camera.camera2.pipe.AudioRestrictionMode
+import androidx.camera.camera2.pipe.CameraDevices
+import androidx.camera.camera2.pipe.CameraGraph
+import androidx.camera.camera2.pipe.CameraMetadata
+import androidx.camera.camera2.pipe.CameraPipe
+import androidx.camera.camera2.pipe.CameraPipe.CameraBackendConfig
+import androidx.camera.camera2.pipe.CameraSurfaceManager
+import kotlinx.atomicfu.atomic
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.TestScope
+
+/**
+ * This class enables a developer to simulate interactions with [CameraPipe].
+ *
+ * This simulator is a realistic fake of a real CameraPipe object with methods that enable a
+ * developer to query and interact with the simulated camera subsystem(s). This is primarily used to
+ * test complicated interactions with [CameraPipe] and [CameraGraph] and to simulate how code
+ * responds to a range of behaviors by the underlying camera within unit tests.
+ */
+class CameraPipeSimulator
+private constructor(
+    private val cameraPipeInternal: CameraPipe,
+    private val fakeCameraBackend: FakeCameraBackend,
+    val fakeSurfaces: FakeSurfaces,
+    val fakeImageReaders: FakeImageReaders,
+    val fakeImageSources: FakeImageSources,
+) : CameraPipe, AutoCloseable {
+    private val closed = atomic(false)
+    private val _cameraGraphs = mutableListOf<CameraGraphSimulator>()
+    val cameraGraphs: List<CameraGraphSimulator>
+        get() = _cameraGraphs
+
+    override fun create(config: CameraGraph.Config): CameraGraphSimulator {
+        check(!closed.value) { "Cannot interact with CameraPipeSimulator after close!" }
+
+        val cameraGraph = cameraPipeInternal.create(config)
+        val fakeCameraController =
+            checkNotNull(fakeCameraBackend.cameraControllers.lastOrNull()) {
+                "Expected cameraPipe.create to create a CameraController instance from " +
+                    "$fakeCameraBackend as part of its initialization."
+            }
+        val cameraMetadata = cameraPipeInternal.cameras().awaitCameraMetadata(config.camera)!!
+        val cameraGraphSimulator =
+            CameraGraphSimulator(
+                cameraMetadata,
+                fakeCameraController,
+                fakeImageReaders,
+                fakeImageSources,
+                cameraGraph,
+                config,
+            )
+        _cameraGraphs.add(cameraGraphSimulator)
+        return cameraGraphSimulator
+    }
+
+    override fun createCameraGraphs(
+        config: CameraGraph.ConcurrentConfig
+    ): List<CameraGraphSimulator> {
+        check(!closed.value) { "Cannot interact with CameraPipeSimulator after close!" }
+        return config.graphConfigs.map { create(it) }
+    }
+
+    override fun cameras(): CameraDevices = cameraPipeInternal.cameras()
+
+    override fun cameraSurfaceManager(): CameraSurfaceManager =
+        cameraPipeInternal.cameraSurfaceManager()
+
+    override var globalAudioRestrictionMode: AudioRestrictionMode
+        get() = cameraPipeInternal.globalAudioRestrictionMode
+        set(value) {
+            cameraPipeInternal.globalAudioRestrictionMode = value
+        }
+
+    /** Directly create and return a new [CameraGraph] and [CameraGraphSimulator]. */
+    fun createCameraGraphSimulator(graphConfig: CameraGraph.Config): CameraGraphSimulator {
+        check(!closed.value) { "Cannot interact with CameraPipeSimulator after close!" }
+        val cameraGraph = cameraPipeInternal.create(graphConfig)
+        val cameraController =
+            fakeCameraBackend.cameraControllers.first { it.cameraGraphId == cameraGraph.id }
+        val cameraGraphSimulator =
+            createCameraGraphSimulator(cameraGraph, graphConfig, cameraController)
+        _cameraGraphs.add(cameraGraphSimulator)
+        return cameraGraphSimulator
+    }
+
+    /** Directly create and return a new set of [CameraGraph]s and [CameraGraphSimulator]s. */
+    fun createCameraGraphSimulators(
+        config: CameraGraph.ConcurrentConfig
+    ): List<CameraGraphSimulator> = config.graphConfigs.map { createCameraGraphSimulator(it) }
+
+    private fun createCameraGraphSimulator(
+        graph: CameraGraph,
+        graphConfig: CameraGraph.Config,
+        cameraController: CameraControllerSimulator
+    ): CameraGraphSimulator {
+        check(!closed.value) { "Cannot interact with CameraPipeSimulator after close!" }
+        val cameraId = cameraController.cameraId
+        val cameraMetadata = fakeCameraBackend.awaitCameraMetadata(cameraController.cameraId)
+        checkNotNull(cameraMetadata) { "Failed to retrieve metadata for $cameraId!" }
+
+        val cameraGraphSimulator =
+            CameraGraphSimulator(
+                cameraMetadata,
+                cameraController,
+                fakeImageReaders,
+                fakeImageSources,
+                graph,
+                graphConfig,
+            )
+        return cameraGraphSimulator
+    }
+
+    fun checkImageReadersClosed() {
+        fakeImageSources.checkImageSourcesClosed()
+        fakeImageReaders.checkImageReadersClosed()
+    }
+
+    fun checkImagesClosed() {
+        fakeImageSources.checkImagesClosed()
+        fakeImageReaders.checkImagesClosed()
+    }
+
+    fun checkCameraGraphsClosed() {
+        for (cameraGraph in _cameraGraphs) {
+            check(cameraGraph.isClosed) { "$cameraGraph was not closed!" }
+        }
+    }
+
+    override fun close() {
+        if (closed.compareAndSet(expect = false, update = true)) {
+            fakeSurfaces.close()
+        }
+    }
+
+    override fun toString(): String {
+        return "CameraPipeSimulator($cameraPipeInternal)"
+    }
+
+    companion object {
+        fun create(
+            testScope: TestScope,
+            testContext: Context,
+            fakeCameras: List<CameraMetadata> = listOf(FakeCameraMetadata())
+        ): CameraPipeSimulator {
+            val fakeCameraBackend =
+                FakeCameraBackend(fakeCameras = fakeCameras.associateBy { it.camera })
+
+            val testScopeDispatcher =
+                StandardTestDispatcher(testScope.testScheduler, "CXCP-TestScope")
+            val testScopeThreadConfig =
+                CameraPipe.ThreadConfig(
+                    testOnlyDispatcher = testScopeDispatcher,
+                    testOnlyScope = testScope,
+                )
+
+            val fakeSurfaces = FakeSurfaces()
+            val fakeImageReaders = FakeImageReaders(fakeSurfaces)
+            val fakeImageSources = FakeImageSources(fakeImageReaders)
+
+            val cameraPipe =
+                CameraPipe(
+                    CameraPipe.Config(
+                        testContext,
+                        cameraBackendConfig =
+                            CameraBackendConfig(internalBackend = fakeCameraBackend),
+                        threadConfig = testScopeThreadConfig,
+                        imageSources = fakeImageSources
+                    )
+                )
+            return CameraPipeSimulator(
+                cameraPipe,
+                fakeCameraBackend,
+                fakeSurfaces,
+                fakeImageReaders,
+                fakeImageSources
+            )
+        }
+    }
+}
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceProcessor.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceProcessor.kt
index 76b0c9f..2647ab5 100644
--- a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceProcessor.kt
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeCaptureSequenceProcessor.kt
@@ -66,7 +66,11 @@
     private var _surfaceMap: Map<StreamId, Surface> = emptyMap()
     var surfaceMap: Map<StreamId, Surface>
         get() = synchronized(lock) { _surfaceMap }
-        set(value) = synchronized(lock) { _surfaceMap = value }
+        set(value) =
+            synchronized(lock) {
+                _surfaceMap = value
+                println("Configured surfaceMap for $this")
+            }
 
     override fun build(
         isRepeating: Boolean,
@@ -86,6 +90,7 @@
     }
 
     override fun submit(captureSequence: FakeCaptureSequence): Int {
+        println("submit $captureSequence")
         synchronized(lock) {
             if (rejectRequests) {
                 check(
@@ -175,6 +180,11 @@
         defaultListeners: List<Request.Listener>,
     ): FakeCaptureSequence? {
         val surfaceMap = surfaceMap
+        if (surfaceMap.isEmpty()) {
+            println("No surfaces configured for $this! Cannot build CaptureSequence for $requests")
+            return null
+        }
+
         val requestInfoMap = mutableMapOf<Request, RequestMetadata>()
         val requestInfoList = mutableListOf<RequestMetadata>()
         for (request in requests) {
@@ -204,15 +214,22 @@
 
             val requestNumber = RequestNumber(requestCounter.incrementAndGet())
             val streamMap = mutableMapOf<StreamId, Surface>()
+            var hasSurface = false
             for (stream in request.streams) {
                 val surface = surfaceMap[stream]
                 if (surface == null) {
-                    println("No surface was set for $stream while building request $request")
-                    return null
+                    println("Failed to find surface for $stream on $request")
+                    continue
                 }
+                hasSurface = true
                 streamMap[stream] = surface
             }
 
+            if (!hasSurface) {
+                println("No surfaces configured for $request! Cannot build CaptureSequence.")
+                return null
+            }
+
             val requestMetadata =
                 FakeRequestMetadata(
                     request = request,
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImage.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImage.kt
index 9c4df35..ee4eb1a 100644
--- a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImage.kt
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImage.kt
@@ -28,6 +28,7 @@
     override val format: Int,
     override val timestamp: Long
 ) : ImageWrapper {
+    private val debugId = debugIds.incrementAndGet()
     private val closed = atomic(false)
     val isClosed: Boolean
         get() = closed.value
@@ -45,4 +46,10 @@
             // FakeImage close is a NoOp
         }
     }
+
+    override fun toString(): String = "FakeImage-$debugId"
+
+    companion object {
+        private val debugIds = atomic(0)
+    }
 }
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageReader.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageReader.kt
index bbd23b0..18e17d8 100644
--- a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageReader.kt
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageReader.kt
@@ -32,34 +32,33 @@
     private val format: StreamFormat,
     override val capacity: Int,
     override val surface: Surface,
-    private val streamId: StreamId,
+    val streamId: StreamId,
     private val outputs: Map<OutputId, Size>
 ) : ImageReaderWrapper {
+    private val debugId = debugIds.incrementAndGet()
     private val closed = atomic(false)
     private val onImageListener = atomic<ImageReaderWrapper.OnImageListener?>(null)
 
+    private val lock = Any()
+    private val _images = mutableListOf<FakeImage>()
+
+    /** Retrieve a list of every image that has been created from this FakeImageReader. */
+    val images: List<FakeImage>
+        get() = synchronized(lock) { _images.toMutableList() }
+
     val isClosed: Boolean
         get() = closed.value
 
     /**
-     * Simulate an image at a specific [timestamp]. The timebase for an imageReader is undefined.
+     * Simulate an image at a specific [imageTimestamp] for a particular (optional) [OutputId]. The
+     * timebase for an imageReader is left undefined.
      */
-    fun simulateImage(timestamp: Long): FakeImage {
-        val outputId = outputs.keys.single()
-        return simulateImage(outputId, timestamp)
-    }
-
-    /**
-     * Simulate an image using a specific [outputId] and [timestamp]. The timebase for an
-     * imageReader is undefined.
-     */
-    fun simulateImage(outputId: OutputId, timestamp: Long): FakeImage {
+    fun simulateImage(imageTimestamp: Long, outputId: OutputId? = null): FakeImage {
+        val output = outputId ?: outputs.keys.single()
         val size =
-            checkNotNull(outputs[outputId]) {
-                "Unexpected $outputId! Available outputs are $outputs"
-            }
-        val image = FakeImage(size.width, size.height, format.value, timestamp)
-        simulateImage(outputId, image)
+            checkNotNull(outputs[output]) { "Unexpected $output! Available outputs are $outputs" }
+        val image = FakeImage(size.width, size.height, format.value, imageTimestamp)
+        simulateImage(image, output)
         return image
     }
 
@@ -67,13 +66,19 @@
      * Simulate an image using a specific [ImageWrapper] for the given outputId. The size must
      * match.
      */
-    fun simulateImage(outputId: OutputId, image: ImageWrapper) {
+    fun simulateImage(image: ImageWrapper, outputId: OutputId) {
         val size =
             checkNotNull(outputs[outputId]) {
                 "Unexpected $outputId! Available outputs are $outputs"
             }
         check(image.width == size.width)
         check(image.height == size.height)
+
+        synchronized(lock) {
+            if (image is FakeImage) {
+                _images.add(image)
+            }
+        }
         onImageListener.value?.onImage(streamId, outputId, image)
     }
 
@@ -96,7 +101,19 @@
         }
     }
 
+    override fun toString(): String = "FakeImageReader-$debugId"
+
+    /** [check] that all images produced by this [FakeImageReader] have been closed. */
+    fun checkImagesClosed() {
+        for ((i, fakeImage) in images.withIndex()) {
+            check(fakeImage.isClosed) {
+                "Failed to close image $i / ${images.size} $fakeImage from $this"
+            }
+        }
+    }
+
     companion object {
+        private val debugIds = atomic(0)
 
         /** Create a [FakeImageReader] that can simulate images. */
         fun create(
@@ -104,21 +121,26 @@
             streamId: StreamId,
             outputId: OutputId,
             size: Size,
-            capacity: Int
-        ): FakeImageReader = create(format, streamId, mapOf(outputId to size), capacity)
+            capacity: Int,
+            fakeSurfaces: FakeSurfaces? = null
+        ): FakeImageReader =
+            create(format, streamId, mapOf(outputId to size), capacity, fakeSurfaces)
 
         /** Create a [FakeImageReader] that can simulate different sized images. */
         fun create(
             format: StreamFormat,
             streamId: StreamId,
             outputIdMap: Map<OutputId, Size>,
-            capacity: Int
+            capacity: Int,
+            fakeSurfaces: FakeSurfaces? = null
         ): FakeImageReader {
 
             // Find smallest by areas to pick the default surface size. This matches the behavior of
             // MultiResolutionImageReader.
             val smallestOutput = outputIdMap.values.minBy { it.width * it.height }
-            val surface = FakeSurfaces.create(smallestOutput)
+            val surface =
+                fakeSurfaces?.createFakeSurface(smallestOutput)
+                    ?: FakeSurfaces.create(smallestOutput)
             return FakeImageReader(format, capacity, surface, streamId, outputIdMap)
         }
     }
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageReaders.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageReaders.kt
new file mode 100644
index 0000000..29253bc
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageReaders.kt
@@ -0,0 +1,100 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import android.util.Size
+import android.view.Surface
+import androidx.annotation.GuardedBy
+import androidx.camera.camera2.pipe.CameraStream
+import androidx.camera.camera2.pipe.ImageSourceConfig
+import androidx.camera.camera2.pipe.OutputId
+import androidx.camera.camera2.pipe.StreamFormat
+import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.media.ImageReaderWrapper
+
+/**
+ * Utility class for creating, tracking, and simulating [FakeImageReader]s. ImageReaders can be
+ * retrieved based on [Surface] or by [StreamId], and supports both single and
+ * MultiResolutionImageReader-like implementations.
+ */
+class FakeImageReaders(private val fakeSurfaces: FakeSurfaces) {
+    private val lock = Any()
+
+    @GuardedBy("lock") private val fakeImageReaders = mutableListOf<FakeImageReader>()
+
+    operator fun get(surface: Surface): FakeImageReader? {
+        return synchronized(lock) { fakeImageReaders.find { it.surface == surface } }
+    }
+
+    operator fun get(streamId: StreamId): FakeImageReader? {
+        return synchronized(lock) { fakeImageReaders.find { it.streamId == streamId } }
+    }
+
+    /** Create a [FakeImageReader] based on a single [CameraStream]. */
+    fun create(cameraStream: CameraStream, capacity: Int) =
+        create(
+            cameraStream.outputs.first().format,
+            cameraStream.id,
+            cameraStream.outputs.associate { it.id to it.size },
+            capacity
+        )
+
+    /** Create a [FakeImageReader] from its properties. */
+    fun create(
+        format: StreamFormat,
+        streamId: StreamId,
+        outputIdMap: Map<OutputId, Size>,
+        capacity: Int,
+        fakeSurfaces: FakeSurfaces? = null
+    ): FakeImageReader {
+        check(this[streamId] == null) {
+            "Cannot create multiple ImageReader(s) from the same $streamId!"
+        }
+
+        val fakeImageReader =
+            FakeImageReader.create(format, streamId, outputIdMap, capacity, fakeSurfaces)
+        synchronized(lock) { fakeImageReaders.add(fakeImageReader) }
+        return fakeImageReader
+    }
+
+    /** Create a [FakeImageReader] based on a [CameraStream] and an [ImageSourceConfig]. */
+    fun create(
+        cameraStream: CameraStream,
+        imageSourceConfig: ImageSourceConfig
+    ): ImageReaderWrapper =
+        create(
+            cameraStream.outputs.first().format,
+            cameraStream.id,
+            cameraStream.outputs.associate { it.id to it.size },
+            imageSourceConfig.capacity,
+            fakeSurfaces
+        )
+
+    /** [check] that all [FakeImageReader]s are closed. */
+    fun checkImageReadersClosed() {
+        for (fakeImageReader in fakeImageReaders) {
+            check(fakeImageReader.isClosed) { "Failed to close ImageReader: $fakeImageReader" }
+        }
+    }
+
+    /** [check] that all images from all [FakeImageReader]s are closed. */
+    fun checkImagesClosed() {
+        for (fakeImageReader in fakeImageReaders) {
+            fakeImageReader.checkImagesClosed()
+        }
+    }
+}
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageSource.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageSource.kt
new file mode 100644
index 0000000..a51af8d
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageSource.kt
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import android.util.Size
+import androidx.camera.camera2.pipe.OutputId
+import androidx.camera.camera2.pipe.StreamFormat
+import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.media.ImageReaderImageSource
+import androidx.camera.camera2.pipe.media.ImageSource
+import kotlinx.atomicfu.atomic
+
+class FakeImageSource
+private constructor(
+    private val fakeImageReader: FakeImageReader,
+    private val imageSource: ImageSource
+) : ImageSource by imageSource {
+    private val debugId = debugIds.incrementAndGet()
+    private val closed = atomic<Boolean>(false)
+    val isClosed: Boolean
+        get() = closed.value
+
+    val streamId: StreamId
+        get() = fakeImageReader.streamId
+
+    /** Retrieve a list of every image that has been created from this FakeImageSource. */
+    val images: List<FakeImage>
+        get() = fakeImageReader.images
+
+    fun simulateImage(timestamp: Long, outputId: OutputId? = null): FakeImage {
+        return fakeImageReader.simulateImage(timestamp, outputId)
+    }
+
+    override fun close() {
+        if (closed.compareAndSet(expect = false, update = true)) {
+            imageSource.close()
+        }
+    }
+
+    override fun toString(): String = "FakeImageSource-$debugId"
+
+    companion object {
+        private val debugIds = atomic(0)
+
+        fun create(
+            streamFormat: StreamFormat,
+            streamId: StreamId,
+            outputs: Map<OutputId, Size>,
+            capacity: Int,
+            fakeImageReaders: FakeImageReaders
+        ): FakeImageSource {
+            val fakeImageReader = fakeImageReaders.create(streamFormat, streamId, outputs, capacity)
+
+            val imageReaderImageSource = ImageReaderImageSource.create(fakeImageReader)
+            return FakeImageSource(fakeImageReader, imageReaderImageSource)
+        }
+    }
+}
diff --git a/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageSources.kt b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageSources.kt
new file mode 100644
index 0000000..ae74060
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/main/java/androidx/camera/camera2/pipe/testing/FakeImageSources.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import android.view.Surface
+import androidx.annotation.GuardedBy
+import androidx.camera.camera2.pipe.CameraStream
+import androidx.camera.camera2.pipe.ImageSourceConfig
+import androidx.camera.camera2.pipe.StreamId
+import androidx.camera.camera2.pipe.media.ImageSource
+import androidx.camera.camera2.pipe.media.ImageSources
+
+/**
+ * Utility class for creating, tracking, and simulating [FakeImageSource]s. [FakeImageSource](s) can
+ * be retrieved based on [Surface] or by [StreamId], and supports both single and
+ * MultiResolutionImageReader-like implementations.
+ */
+class FakeImageSources(private val fakeImageReaders: FakeImageReaders) : ImageSources {
+    private val lock = Any()
+
+    @GuardedBy("lock") private val fakeImageSources = mutableListOf<FakeImageSource>()
+
+    operator fun get(surface: Surface): FakeImageSource? {
+        return synchronized(lock) { fakeImageSources.find { it.surface == surface } }
+    }
+
+    operator fun get(streamId: StreamId): FakeImageSource? {
+        return synchronized(lock) { fakeImageSources.find { it.streamId == streamId } }
+    }
+
+    override fun createImageSource(
+        cameraStream: CameraStream,
+        imageSourceConfig: ImageSourceConfig
+    ): ImageSource {
+        check(this[cameraStream.id] == null) {
+            "Cannot create multiple ImageSource(s) from the same $cameraStream!"
+        }
+        val fakeImageSource =
+            FakeImageSource.create(
+                cameraStream.outputs.first().format,
+                cameraStream.id,
+                cameraStream.outputs.associate { it.id to it.size },
+                imageSourceConfig.capacity,
+                fakeImageReaders
+            )
+        synchronized(lock) { fakeImageSources.add(fakeImageSource) }
+        return fakeImageSource
+    }
+
+    /** [check] that all [FakeImageSource]s are closed. */
+    fun checkImageSourcesClosed() =
+        synchronized(lock) {
+            for (fakeImageSource in fakeImageSources) {
+                check(fakeImageSource.isClosed) { "Failed to close ImageSource!: $fakeImageSource" }
+            }
+        }
+
+    /** [check] that all images from all [FakeImageReader]s are closed. */
+    fun checkImagesClosed() =
+        synchronized(lock) {
+            for ((i, fakeImageSource) in fakeImageSources.withIndex()) {
+                for ((j, fakeImage) in fakeImageSource.images.withIndex()) {
+                    check(fakeImage.isClosed) {
+                        "Failed to close $fakeImage ($j / " +
+                            "${fakeImageSource.images.size}) from $fakeImageSource " +
+                            "($i / ${fakeImageSources.size})"
+                    }
+                }
+            }
+        }
+}
diff --git a/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulatorTest.kt b/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulatorTest.kt
index a30a1e04..f0cf2e8 100644
--- a/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulatorTest.kt
+++ b/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraGraphSimulatorTest.kt
@@ -72,13 +72,13 @@
     @Test
     fun simulatorCanSimulateRepeatingFrames() =
         testScope.runTest {
-            val stream = simulator.cameraGraph.streams[streamConfig]!!
+            val stream = simulator.streams[streamConfig]!!
             val listener = FakeRequestListener()
             val request = Request(streams = listOf(stream.id), listeners = listOf(listener))
-            simulator.cameraGraph.acquireSession().use { it.startRepeating(request) }
-            simulator.cameraGraph.start()
+            simulator.acquireSession().use { it.startRepeating(request) }
+            simulator.start()
             simulator.simulateCameraStarted()
-            simulator.simulateFakeSurfaceConfiguration()
+            simulator.initializeSurfaces()
             advanceUntilIdle()
 
             val frame = simulator.simulateNextFrame()
@@ -160,12 +160,12 @@
     @Test
     fun simulatorAbortsRequests() =
         testScope.runTest {
-            val stream = simulator.cameraGraph.streams[streamConfig]!!
+            val stream = simulator.streams[streamConfig]!!
             val listener = FakeRequestListener()
             val request = Request(streams = listOf(stream.id), listeners = listOf(listener))
 
-            simulator.cameraGraph.acquireSession().use { it.submit(request = request) }
-            simulator.cameraGraph.close()
+            simulator.acquireSession().use { it.submit(request = request) }
+            simulator.close()
 
             val abortedEvent = listener.onAbortedFlow.first()
             assertThat(abortedEvent.request).isSameInstanceAs(request)
@@ -174,15 +174,15 @@
     @Test
     fun simulatorCanIssueBufferLoss() =
         testScope.runTest {
-            val stream = simulator.cameraGraph.streams[streamConfig]!!
+            val stream = simulator.streams[streamConfig]!!
             val listener = FakeRequestListener()
             val request = Request(streams = listOf(stream.id), listeners = listOf(listener))
 
-            simulator.cameraGraph.acquireSession().use { it.submit(request = request) }
+            simulator.acquireSession().use { it.submit(request = request) }
 
-            simulator.cameraGraph.start()
+            simulator.start()
             simulator.simulateCameraStarted()
-            simulator.simulateFakeSurfaceConfiguration()
+            simulator.initializeSurfaces()
             advanceUntilIdle()
 
             val frame = simulator.simulateNextFrame()
@@ -198,14 +198,14 @@
     @Test
     fun simulatorCanIssueMultipleFrames() =
         testScope.runTest {
-            val stream = simulator.cameraGraph.streams[streamConfig]!!
+            val stream = simulator.streams[streamConfig]!!
             val listener = FakeRequestListener()
             val request = Request(streams = listOf(stream.id), listeners = listOf(listener))
 
-            simulator.cameraGraph.acquireSession().use { it.startRepeating(request = request) }
-            simulator.cameraGraph.start()
+            simulator.acquireSession().use { it.startRepeating(request = request) }
+            simulator.start()
             simulator.simulateCameraStarted()
-            simulator.simulateFakeSurfaceConfiguration()
+            simulator.initializeSurfaces()
             advanceUntilIdle()
 
             val frame1 = simulator.simulateNextFrame()
@@ -283,19 +283,19 @@
     @Test
     fun simulatorCanSimulateGraphState() =
         testScope.runTest {
-            assertThat(simulator.cameraGraph.graphState.value).isEqualTo(GraphStateStopped)
+            assertThat(simulator.graphState.value).isEqualTo(GraphStateStopped)
 
-            simulator.cameraGraph.start()
-            assertThat(simulator.cameraGraph.graphState.value).isEqualTo(GraphStateStarting)
+            simulator.start()
+            assertThat(simulator.graphState.value).isEqualTo(GraphStateStarting)
 
             simulator.simulateCameraStarted()
-            assertThat(simulator.cameraGraph.graphState.value).isEqualTo(GraphStateStarted)
+            assertThat(simulator.graphState.value).isEqualTo(GraphStateStarted)
 
-            simulator.cameraGraph.stop()
-            assertThat(simulator.cameraGraph.graphState.value).isEqualTo(GraphStateStopping)
+            simulator.stop()
+            assertThat(simulator.graphState.value).isEqualTo(GraphStateStopping)
 
             simulator.simulateCameraStopped()
-            assertThat(simulator.cameraGraph.graphState.value).isEqualTo(GraphStateStopped)
+            assertThat(simulator.graphState.value).isEqualTo(GraphStateStopped)
         }
 
     @Test
@@ -305,22 +305,22 @@
 
             simulator.simulateCameraError(error)
             // The CameraGraph is stopped at this point, so the errors should be ignored.
-            assertThat(simulator.cameraGraph.graphState.value).isEqualTo(GraphStateStopped)
+            assertThat(simulator.graphState.value).isEqualTo(GraphStateStopped)
 
-            simulator.cameraGraph.start()
+            simulator.start()
             simulator.simulateCameraError(error)
-            val graphState = simulator.cameraGraph.graphState.value
+            val graphState = simulator.graphState.value
             assertThat(graphState).isInstanceOf(GraphStateError::class.java)
             val graphStateError = graphState as GraphStateError
             assertThat(graphStateError.cameraError).isEqualTo(error.cameraError)
             assertThat(graphStateError.willAttemptRetry).isEqualTo(error.willAttemptRetry)
 
             simulator.simulateCameraStarted()
-            assertThat(simulator.cameraGraph.graphState.value).isEqualTo(GraphStateStarted)
+            assertThat(simulator.graphState.value).isEqualTo(GraphStateStarted)
 
-            simulator.cameraGraph.stop()
+            simulator.stop()
             simulator.simulateCameraStopped()
             simulator.simulateCameraError(error)
-            assertThat(simulator.cameraGraph.graphState.value).isEqualTo(GraphStateStopped)
+            assertThat(simulator.graphState.value).isEqualTo(GraphStateStopped)
         }
 }
diff --git a/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraPipeSimulatorTest.kt b/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraPipeSimulatorTest.kt
new file mode 100644
index 0000000..d7373f1
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/CameraPipeSimulatorTest.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import android.content.Context
+import android.hardware.camera2.CameraCharacteristics
+import android.os.Build
+import android.util.Size
+import androidx.camera.camera2.pipe.CameraGraph
+import androidx.camera.camera2.pipe.CameraStream
+import androidx.camera.camera2.pipe.StreamFormat
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertThrows
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricCameraPipeTestRunner::class)
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class CameraPipeSimulatorTest {
+    private val testScope = TestScope()
+    private val frontCameraMetadata =
+        FakeCameraMetadata(
+            mapOf(CameraCharacteristics.LENS_FACING to CameraCharacteristics.LENS_FACING_FRONT)
+        )
+    private val backCameraMetadata =
+        FakeCameraMetadata(
+            mapOf(CameraCharacteristics.LENS_FACING to CameraCharacteristics.LENS_FACING_BACK)
+        )
+
+    private val streamConfig = CameraStream.Config.create(Size(640, 480), StreamFormat.YUV_420_888)
+    private val graphConfig =
+        CameraGraph.Config(camera = frontCameraMetadata.camera, streams = listOf(streamConfig))
+
+    private val context = ApplicationProvider.getApplicationContext() as Context
+    private val cameraPipe =
+        CameraPipeSimulator.create(
+            testScope,
+            context,
+            listOf(frontCameraMetadata, backCameraMetadata)
+        )
+
+    @Test
+    fun cameraPipeSimulatorCanCreateCameraGraphSimulators() =
+        testScope.runTest {
+            val cameraGraph1 = cameraPipe.create(graphConfig)
+            val cameraGraphSimulator1 = cameraPipe.cameraGraphs.find { it == cameraGraph1 }
+
+            assertThat(cameraGraph1).isInstanceOf(CameraGraphSimulator::class.java)
+            assertThat(cameraGraph1).isSameInstanceAs(cameraGraphSimulator1)
+
+            // Assert that a new CameraGraph can be created with the same graphConfig and that they
+            // produce different CameraGraph instances and simulators.
+            val cameraGraphSimulator3 = cameraPipe.createCameraGraphSimulator(graphConfig)
+            assertThat(cameraGraphSimulator3).isNotSameInstanceAs(cameraGraphSimulator1)
+        }
+
+    @Test
+    fun cameraPipeSimulatorHasMetadataViaCameraPipe() =
+        testScope.runTest {
+            val cameraIds = cameraPipe.cameras().getCameraIds()
+
+            assertThat(cameraIds).isNotNull()
+            assertThat(cameraIds!!.size).isEqualTo(2)
+
+            val firstCameraId = cameraIds.first()
+            val firstMetadata = cameraPipe.cameras().getCameraMetadata(firstCameraId)
+
+            assertThat(firstMetadata).isNotNull()
+            assertThat(firstMetadata).isSameInstanceAs(frontCameraMetadata)
+            assertThat(firstMetadata!!.camera).isEqualTo(firstCameraId)
+
+            val lastCameraId = cameraIds.last()
+            val lastCameraMetadata = cameraPipe.cameras().getCameraMetadata(lastCameraId)
+
+            assertThat(lastCameraMetadata).isNotNull()
+            assertThat(lastCameraMetadata).isSameInstanceAs(backCameraMetadata)
+            assertThat(lastCameraMetadata!!.camera).isEqualTo(lastCameraId)
+        }
+
+    @Test
+    fun cameraPipeSimulatorCanCreateDualCameraGraphs() =
+        testScope.runTest {
+            val cameraIds = cameraPipe.cameras().getCameraIds()
+
+            assertThat(cameraIds).isNotNull()
+            assertThat(cameraIds!!.size).isEqualTo(2)
+
+            val firstCameraId = cameraIds.first()
+            val firstMetadata = cameraPipe.cameras().getCameraMetadata(firstCameraId)
+
+            assertThat(firstMetadata).isNotNull()
+            assertThat(firstMetadata).isSameInstanceAs(frontCameraMetadata)
+            assertThat(firstMetadata!!.camera).isEqualTo(firstCameraId)
+        }
+
+    @Test
+    fun cameraPipeSimulatorCanVerifyCameraGraphConstructionOrder() {
+        val graphConfig1 =
+            CameraGraph.Config(camera = frontCameraMetadata.camera, streams = listOf(streamConfig))
+        val graphConfig2 =
+            CameraGraph.Config(camera = backCameraMetadata.camera, streams = listOf(streamConfig))
+        val graphConfig3 =
+            CameraGraph.Config(camera = frontCameraMetadata.camera, streams = listOf(streamConfig))
+
+        val cameraGraph1 = cameraPipe.create(graphConfig1)
+        val cameraGraph2 = cameraPipe.create(graphConfig2)
+        val cameraGraph3 = cameraPipe.create(graphConfig3)
+
+        assertThat(cameraPipe.cameraGraphs)
+            .containsExactly(cameraGraph1, cameraGraph2, cameraGraph3)
+            .inOrder()
+
+        cameraGraph1.close()
+        cameraGraph2.close()
+        cameraGraph3.close()
+    }
+
+    @Test
+    fun cameraPipeSimulatorCanCheckForUnclosedResources() {
+        val cameraGraph = cameraPipe.create(graphConfig)
+        val fakeImageReader =
+            cameraPipe.fakeImageReaders.create(cameraGraph.streams[streamConfig]!!, 1)
+        val fakeImage = fakeImageReader.simulateImage(123)
+
+        // Assert that these throw exceptions
+        assertThrows(IllegalStateException::class.java) { cameraPipe.checkImageReadersClosed() }
+        assertThrows(IllegalStateException::class.java) { cameraPipe.checkImagesClosed() }
+        assertThrows(IllegalStateException::class.java) { cameraPipe.checkCameraGraphsClosed() }
+
+        // Close everything
+        fakeImage.close()
+        fakeImageReader.close()
+        cameraGraph.close()
+
+        // Assert that these no longer throw exceptions.
+        cameraPipe.checkCameraGraphsClosed()
+        cameraPipe.checkImageReadersClosed()
+        cameraPipe.checkImagesClosed()
+    }
+
+    @Test
+    fun cameraPipeSimulatorCanCreateConcurrentCameraGraphs() {
+        val config1 =
+            CameraGraph.Config(
+                camera = frontCameraMetadata.camera,
+                streams = listOf(streamConfig),
+            )
+        val config2 =
+            CameraGraph.Config(
+                camera = backCameraMetadata.camera,
+                streams = listOf(streamConfig),
+            )
+        val concurrentCameras = listOf(config1, config2)
+
+        val cameraGraphs =
+            cameraPipe.createCameraGraphs(CameraGraph.ConcurrentConfig(concurrentCameras))
+
+        assertThat(cameraGraphs.size).isEqualTo(2)
+        assertThat(cameraGraphs[0].config.camera).isEqualTo(frontCameraMetadata.camera)
+        assertThat(cameraGraphs[1].config.camera).isEqualTo(backCameraMetadata.camera)
+
+        val config1Stream1 = cameraGraphs[0].streams[streamConfig]
+        val config2Stream1 = cameraGraphs[1].streams[streamConfig]
+        assertThat(config1Stream1).isNotEqualTo(config2Stream1)
+    }
+}
diff --git a/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/FrameCaptureTests.kt b/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/FrameCaptureTests.kt
new file mode 100644
index 0000000..a513cea
--- /dev/null
+++ b/camera/camera-camera2-pipe-testing/src/test/java/androidx/camera/camera2/pipe/testing/FrameCaptureTests.kt
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.camera2.pipe.testing
+
+import android.content.Context
+import android.os.Build
+import android.util.Size
+import androidx.camera.camera2.pipe.CameraGraph
+import androidx.camera.camera2.pipe.CameraStream
+import androidx.camera.camera2.pipe.Frame.Companion.isFrameInfoAvailable
+import androidx.camera.camera2.pipe.GraphState.GraphStateStarted
+import androidx.camera.camera2.pipe.GraphState.GraphStateStarting
+import androidx.camera.camera2.pipe.GraphState.GraphStateStopped
+import androidx.camera.camera2.pipe.ImageSourceConfig
+import androidx.camera.camera2.pipe.OutputStatus
+import androidx.camera.camera2.pipe.Request
+import androidx.camera.camera2.pipe.StreamFormat
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricCameraPipeTestRunner::class)
+@Config(minSdk = Build.VERSION_CODES.LOLLIPOP)
+class FrameCaptureTests {
+    private val testScope = TestScope()
+    private val testContext = ApplicationProvider.getApplicationContext() as Context
+    private val cameraPipeSimulator = CameraPipeSimulator.create(testScope, testContext)
+    private val cameraId = cameraPipeSimulator.cameras().awaitCameraIds()!!.first()
+    private val cameraMetadata = cameraPipeSimulator.cameras().awaitCameraMetadata(cameraId)!!
+
+    private val viewfinderStreamConfig =
+        CameraStream.Config.create(Size(640, 480), StreamFormat.UNKNOWN)
+
+    private val jpegStreamConfig =
+        CameraStream.Config.create(
+            Size(640, 480),
+            StreamFormat.YUV_420_888,
+            imageSourceConfig = ImageSourceConfig(capacity = 10)
+        )
+
+    private val graphConfig =
+        CameraGraph.Config(
+            camera = cameraMetadata.camera,
+            streams = listOf(viewfinderStreamConfig, jpegStreamConfig)
+        )
+
+    private val cameraGraphSimulator = cameraPipeSimulator.createCameraGraphSimulator(graphConfig)
+    private val cameraGraph: CameraGraph = cameraGraphSimulator
+
+    private val viewfinderStream = cameraGraph.streams[viewfinderStreamConfig]!!
+    private val jpegStream = cameraGraph.streams[jpegStreamConfig]!!
+
+    private suspend fun startCameraGraph() {
+        assertThat(cameraGraph.graphState.value).isEqualTo(GraphStateStopped)
+
+        cameraGraph.start() // Tell the cameraGraph to start
+        assertThat(cameraGraph.graphState.value).isEqualTo(GraphStateStarting)
+
+        cameraGraphSimulator.initializeSurfaces()
+        cameraGraphSimulator.simulateCameraStarted() // Simulate the camera starting successfully
+        assertThat(cameraGraph.graphState.value).isEqualTo(GraphStateStarted)
+    }
+
+    @Test
+    fun frameCaptureCanBeSimulated() =
+        testScope.runTest {
+            startCameraGraph()
+
+            // Capture an image using the cameraGraph
+            val frameCapture =
+                cameraGraph.useSession { session ->
+                    session.capture(Request(streams = listOf(jpegStream.id)))
+                }
+            advanceUntilIdle()
+
+            // Verify a capture sequence with all of the frame interactions
+            val frameCaptureJob = launch {
+                // TODO: Should awaitFrame be called awaitFrameStarted?
+                // TODO: Should there be an awaitComplete() function?
+                val frame = frameCapture.awaitFrame()
+                assertThat(frame).isNotNull()
+
+                assertThat(frame!!.frameId.value).isGreaterThan(0)
+                assertThat(frame.frameTimestamp.value).isGreaterThan(0)
+
+                val image = frame.awaitImage(jpegStream.id)
+                assertThat(frame.imageStatus(jpegStream.id)).isEqualTo(OutputStatus.AVAILABLE)
+                assertThat(frame.imageStatus(viewfinderStream.id))
+                    .isEqualTo(OutputStatus.UNAVAILABLE)
+                assertThat(image).isNotNull()
+                assertThat(image!!.timestamp).isEqualTo(frame.frameTimestamp.value)
+
+                image.close()
+
+                assertThat(frame.imageStatus(jpegStream.id)).isEqualTo(OutputStatus.AVAILABLE)
+                assertThat(frame.imageStatus(viewfinderStream.id))
+                    .isEqualTo(OutputStatus.UNAVAILABLE)
+
+                println("frame.awaitFrameInfo()")
+                val frameInfo = frame.awaitFrameInfo()
+
+                assertThat(frame.isFrameInfoAvailable).isTrue()
+                assertThat(frameInfo).isNotNull()
+                assertThat(frameInfo!!.frameNumber).isEqualTo(frame.frameNumber)
+
+                println("frame.close()")
+                frame.close()
+
+                assertThat(frame.imageStatus(jpegStream.id)).isEqualTo(OutputStatus.UNAVAILABLE)
+                assertThat(frame.imageStatus(viewfinderStream.id))
+                    .isEqualTo(OutputStatus.UNAVAILABLE)
+                assertThat(frame.isFrameInfoAvailable).isFalse()
+            }
+
+            // Simulate camera interactions:
+            // TODO: simulateFrameStarted?
+            val frameSimulator = cameraGraphSimulator.simulateNextFrame()
+            frameSimulator.simulateImage(jpegStream.id)
+            frameSimulator.simulateComplete(emptyMap())
+
+            // TODO: should this have a way to check to make sure all frames are closed?
+            // cameraGraph?
+
+            advanceUntilIdle()
+            assertThat(frameCaptureJob.isCompleted) // Ensure verification is complete
+            cameraGraphSimulator.close()
+        }
+}
diff --git a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/media/ImageReaderImageSourceTest.kt b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/media/ImageReaderImageSourceTest.kt
index 0c32cd2..804016b 100644
--- a/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/media/ImageReaderImageSourceTest.kt
+++ b/camera/camera-camera2-pipe/src/test/java/androidx/camera/camera2/pipe/media/ImageReaderImageSourceTest.kt
@@ -109,7 +109,7 @@
         val image =
             FakeImage(fakeImageSize.width, fakeImageSize.height, fakeImageFormat.value, 12345)
 
-        fakeImageReader.simulateImage(outputId, image)
+        fakeImageReader.simulateImage(image, outputId)
         assertThat(image.isClosed).isTrue()
     }
 
@@ -181,7 +181,7 @@
         // Now simulate the imageReader producing images after the imageSource is closed
         val fakeImage =
             FakeImage(fakeImageSize.width, fakeImageSize.height, fakeImageFormat.value, 54321)
-        fakeImageReader.simulateImage(outputId, fakeImage)
+        fakeImageReader.simulateImage(fakeImage, outputId)
         // Image is immediately closed
         assertThat(fakeImage.isClosed)