Merge "[Unit test lib] Add `runGlanceAppWidgetUnitTest` similar to compose's `runComposeUiTest` that provides scoped functions such as `provideContent`, `onNode` etc. to be able to write unit tests." into androidx-main
diff --git a/docs-tip-of-tree/build.gradle b/docs-tip-of-tree/build.gradle
index 0492be7..d65c0f9 100644
--- a/docs-tip-of-tree/build.gradle
+++ b/docs-tip-of-tree/build.gradle
@@ -191,7 +191,9 @@
     docs(project(":fragment:fragment-testing"))
     docs(project(":glance:glance"))
     docs(project(":glance:glance-appwidget"))
+    docs(project(":glance:glance-appwidget-testing"))
     samples(project(":glance:glance-appwidget:glance-appwidget-samples"))
+    samples(project(":glance:glance-appwidget-testing:glance-appwidget-testing-samples"))
     docs(project(":glance:glance-appwidget-preview"))
     docs(project(":glance:glance-preview"))
     docs(project(":glance:glance-testing"))
diff --git a/glance/glance-appwidget-testing/api/current.txt b/glance/glance-appwidget-testing/api/current.txt
new file mode 100644
index 0000000..4c77ba8
--- /dev/null
+++ b/glance/glance-appwidget-testing/api/current.txt
@@ -0,0 +1,24 @@
+// Signature format: 4.0
+package androidx.glance.appwidget.testing.unit {
+
+  public sealed interface GlanceAppWidgetUnitTest extends androidx.glance.testing.GlanceNodeAssertionsProvider<androidx.glance.testing.unit.MappedNode,androidx.glance.testing.unit.GlanceMappedNode> {
+    method public void awaitIdle();
+    method public void provideComposable(kotlin.jvm.functions.Function0<kotlin.Unit> composable);
+    method public void setAppWidgetSize(long size);
+    method public void setContext(android.content.Context context);
+    method public <T> void setState(T state);
+  }
+
+  public final class GlanceAppWidgetUnitTestDefaults {
+    method public androidx.glance.GlanceId glanceId();
+    method public int hostCategory();
+    method public long size();
+    field public static final androidx.glance.appwidget.testing.unit.GlanceAppWidgetUnitTestDefaults INSTANCE;
+  }
+
+  public final class GlanceAppWidgetUnitTestKt {
+    method public static void runGlanceAppWidgetUnitTest(optional long timeout, kotlin.jvm.functions.Function1<? super androidx.glance.appwidget.testing.unit.GlanceAppWidgetUnitTest,kotlin.Unit> block);
+  }
+
+}
+
diff --git a/glance/glance-appwidget-testing/api/res-current.txt b/glance/glance-appwidget-testing/api/res-current.txt
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/glance/glance-appwidget-testing/api/res-current.txt
diff --git a/glance/glance-appwidget-testing/api/restricted_current.txt b/glance/glance-appwidget-testing/api/restricted_current.txt
new file mode 100644
index 0000000..4c77ba8
--- /dev/null
+++ b/glance/glance-appwidget-testing/api/restricted_current.txt
@@ -0,0 +1,24 @@
+// Signature format: 4.0
+package androidx.glance.appwidget.testing.unit {
+
+  public sealed interface GlanceAppWidgetUnitTest extends androidx.glance.testing.GlanceNodeAssertionsProvider<androidx.glance.testing.unit.MappedNode,androidx.glance.testing.unit.GlanceMappedNode> {
+    method public void awaitIdle();
+    method public void provideComposable(kotlin.jvm.functions.Function0<kotlin.Unit> composable);
+    method public void setAppWidgetSize(long size);
+    method public void setContext(android.content.Context context);
+    method public <T> void setState(T state);
+  }
+
+  public final class GlanceAppWidgetUnitTestDefaults {
+    method public androidx.glance.GlanceId glanceId();
+    method public int hostCategory();
+    method public long size();
+    field public static final androidx.glance.appwidget.testing.unit.GlanceAppWidgetUnitTestDefaults INSTANCE;
+  }
+
+  public final class GlanceAppWidgetUnitTestKt {
+    method public static void runGlanceAppWidgetUnitTest(optional long timeout, kotlin.jvm.functions.Function1<? super androidx.glance.appwidget.testing.unit.GlanceAppWidgetUnitTest,kotlin.Unit> block);
+  }
+
+}
+
diff --git a/glance/glance-appwidget-testing/build.gradle b/glance/glance-appwidget-testing/build.gradle
new file mode 100644
index 0000000..8d5db0b
--- /dev/null
+++ b/glance/glance-appwidget-testing/build.gradle
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2023 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.
+ */
+
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("AndroidXComposePlugin")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    api(libs.kotlinStdlib)
+    api(libs.kotlinCoroutinesTest)
+    api(project(":glance:glance-testing"))
+    api(project(":glance:glance-appwidget"))
+
+    testImplementation("androidx.core:core:1.7.0")
+    testImplementation("androidx.core:core-ktx:1.7.0")
+    testImplementation(libs.junit)
+    testImplementation(libs.kotlinCoroutinesTest)
+    testImplementation(libs.kotlinTest)
+    testImplementation(libs.robolectric)
+    testImplementation(libs.testCore)
+    testImplementation(libs.testRunner)
+    testImplementation(libs.truth)
+
+    samples(projectOrArtifact(":glance:glance-appwidget-testing:glance-appwidget-testing-samples"))
+}
+
+android {
+    testOptions {
+        unitTests {
+            includeAndroidResources = true
+        }
+    }
+
+    defaultConfig {
+        minSdkVersion 23
+    }
+    namespace "androidx.glance.appwidget.testing"
+}
+
+androidx {
+    name = "androidx.glance:glance-appwidget-testing"
+    type = LibraryType.PUBLISHED_LIBRARY
+    targetsJavaConsumers = false
+    inceptionYear = "2023"
+    description = "This library provides APIs for developers to use for testing their appWidget specific Glance composables."
+}
diff --git a/glance/glance-appwidget-testing/samples/build.gradle b/glance/glance-appwidget-testing/samples/build.gradle
new file mode 100644
index 0000000..a5da7fa1
--- /dev/null
+++ b/glance/glance-appwidget-testing/samples/build.gradle
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2023 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.
+ */
+
+import androidx.build.LibraryType
+
+plugins {
+    id("AndroidXPlugin")
+    id("com.android.library")
+    id("AndroidXComposePlugin")
+    id("org.jetbrains.kotlin.android")
+}
+
+dependencies {
+    implementation(libs.kotlinStdlib)
+    compileOnly(project(":annotation:annotation-sampled"))
+
+    implementation(project(":glance:glance"))
+    implementation(project(":glance:glance-testing"))
+    implementation(project(":glance:glance-appwidget-testing"))
+
+    implementation(libs.junit)
+    implementation(libs.testCore)
+    implementation("androidx.core:core:1.7.0")
+    implementation("androidx.core:core-ktx:1.7.0")
+}
+
+androidx {
+    name = "Glance AppWidget Testing Samples"
+    type = LibraryType.SAMPLES
+    targetsJavaConsumers = false
+    inceptionYear = "2023"
+    description = "Contains the sample code for testing the Glance AppWidget Composables"
+}
+
+android {
+    defaultConfig {
+        minSdkVersion 23
+    }
+    namespace "androidx.glance.appwidget.testing.samples"
+}
diff --git a/glance/glance-appwidget-testing/samples/src/main/java/androidx/glance/appwidget/testing/samples/IsolatedGlanceComposableTestSamples.kt b/glance/glance-appwidget-testing/samples/src/main/java/androidx/glance/appwidget/testing/samples/IsolatedGlanceComposableTestSamples.kt
new file mode 100644
index 0000000..28a29c2
--- /dev/null
+++ b/glance/glance-appwidget-testing/samples/src/main/java/androidx/glance/appwidget/testing/samples/IsolatedGlanceComposableTestSamples.kt
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2023 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.glance.appwidget.testing.samples
+
+import androidx.annotation.Sampled
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceModifier
+import androidx.glance.LocalSize
+import androidx.glance.appwidget.testing.unit.runGlanceAppWidgetUnitTest
+import androidx.glance.layout.Column
+import androidx.glance.layout.Row
+import androidx.glance.layout.Spacer
+import androidx.glance.layout.fillMaxSize
+import androidx.glance.layout.width
+import androidx.glance.semantics.semantics
+import androidx.glance.semantics.testTag
+import androidx.glance.testing.unit.hasTestTag
+import androidx.glance.testing.unit.hasText
+import androidx.glance.text.Text
+import org.junit.Test
+
+@Sampled
+@Suppress("unused")
+fun isolatedGlanceComposableTestSamples() {
+    class TestSample {
+        @Test
+        fun statusContent_statusFalse_outputsPending() = runGlanceAppWidgetUnitTest {
+            provideComposable {
+                StatusRow(
+                    status = false
+                )
+            }
+
+            onNode(hasTestTag("status-text"))
+                .assert(hasText("Pending"))
+        }
+
+        @Test
+        fun statusContent_statusTrue_outputsFinished() = runGlanceAppWidgetUnitTest {
+            provideComposable {
+                StatusRow(
+                    status = true
+                )
+            }
+
+            onNode(hasTestTag("status-text"))
+                .assert(hasText("Finished"))
+        }
+
+        @Test
+        fun header_smallSize_showsShortHeaderText() = runGlanceAppWidgetUnitTest {
+            setAppWidgetSize(DpSize(width = 50.dp, height = 100.dp))
+
+            provideComposable {
+                StatusRow(
+                    status = false
+                )
+            }
+
+            onNode(hasTestTag("header-text"))
+                .assert(hasText("MyApp"))
+        }
+
+        @Test
+        fun header_largeSize_showsLongHeaderText() = runGlanceAppWidgetUnitTest {
+            setAppWidgetSize(DpSize(width = 150.dp, height = 100.dp))
+
+            provideComposable {
+                StatusRow(
+                    status = false
+                )
+            }
+
+            onNode(hasTestTag("header-text"))
+                .assert(hasText("MyApp (Last order)"))
+        }
+
+        @Composable
+        fun WidgetContent(status: Boolean) {
+            Column {
+                Header()
+                Spacer()
+                StatusRow(status)
+            }
+        }
+
+        @Composable
+        fun Header() {
+            val width = LocalSize.current.width
+            Row(modifier = GlanceModifier.fillMaxSize()) {
+                Text(
+                    text = if (width > 50.dp) {
+                        "MyApp (Last order)"
+                    } else {
+                        "MyApp"
+                    },
+                    modifier = GlanceModifier.semantics { testTag = "header-text" }
+                )
+            }
+        }
+
+        @Composable
+        fun StatusRow(status: Boolean) {
+            Row(modifier = GlanceModifier.fillMaxSize()) {
+                Text(
+                    text = "Status",
+                )
+                Spacer(modifier = GlanceModifier.width(10.dp))
+                Text(
+                    text = if (status) {
+                        "Pending"
+                    } else {
+                        "Finished"
+                    },
+                    modifier = GlanceModifier.semantics { testTag = "status-text" }
+                )
+            }
+        }
+    }
+}
diff --git a/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTest.kt b/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTest.kt
new file mode 100644
index 0000000..c6282eb
--- /dev/null
+++ b/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTest.kt
@@ -0,0 +1,156 @@
+/*
+ * Copyright 2023 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.glance.appwidget.testing.unit
+
+import android.appwidget.AppWidgetProviderInfo
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.glance.GlanceId
+import androidx.glance.appwidget.AppWidgetId
+import androidx.glance.state.GlanceStateDefinition
+import androidx.glance.testing.GlanceNodeAssertionsProvider
+import androidx.glance.testing.unit.GlanceMappedNode
+import androidx.glance.testing.unit.MappedNode
+import kotlin.time.Duration
+
+/**
+ * Sets up the test environment and runs the given unit [test block][block]. Use the methods on
+ * [GlanceAppWidgetUnitTest] in the test to provide Glance composable content, find Glance elements
+ * and make assertions on them.
+ *
+ * Test your individual Glance composable functions in isolation to verify that your logic outputs
+ * right elements. For example: if input data is 'x', an image 'y' was
+ * outputted. In sample below, the test class has a separate test for the header and the status
+ * row.
+ *
+ * Tests can be run on JVM as these don't involve rendering the UI. If your logic depends on
+ * [Context] or other android APIs, tests can be run on Android unit testing frameworks such as
+ * [Robolectric](https://github.com/robolectric/robolectric).
+ *
+ * Note: Keeping a reference to the [GlanceAppWidgetUnitTest] outside of this function is an error.
+ *
+ * @sample androidx.glance.appwidget.testing.samples.isolatedGlanceComposableTestSamples
+ *
+ * @param timeout test time out; defaults to 10s
+ * @param block The test block that involves calling methods in [GlanceAppWidgetUnitTest]
+ */
+// This and backing environment is based on pattern followed by
+// "androidx.compose.ui.test.runComposeUiTest". Alternative of exposing testRule was explored, but
+// it wasn't necessary for this case. If developers wish, they may use this function to create their
+// own test rule.
+fun runGlanceAppWidgetUnitTest(
+    timeout: Duration = DEFAULT_TIMEOUT,
+    block: GlanceAppWidgetUnitTest.() -> Unit
+) = GlanceAppWidgetUnitTestEnvironment(timeout).runTest(block)
+
+/**
+ * Provides methods to enable you to test your logic of building Glance composable content in the
+ * [runGlanceAppWidgetUnitTest] scope.
+ *
+ * @see [runGlanceAppWidgetUnitTest]
+ */
+sealed interface GlanceAppWidgetUnitTest :
+    GlanceNodeAssertionsProvider<MappedNode, GlanceMappedNode> {
+    /**
+     * Sets the size of the appWidget to be assumed for the test. This corresponds to the
+     * `LocalSize.current` composition local. If you are accessing the local size, you must
+     * call this method to set the intended size for the test.
+     *
+     * Note: This should be called before calling [provideComposable].
+     * Default is `349.dp, 455.dp` that of a 5x4 widget in Pixel 4 portrait mode. See
+     * [GlanceAppWidgetUnitTestDefaults.size]
+     *
+     * 1. If your appWidget uses `sizeMode == Single`, you can set this to the `minWidth` and
+     * `minHeight` set in your appwidget info xml.
+     * 2. If your appWidget uses `sizeMode == Exact`, you can identify the sizes to test looking
+     * at the documentation on
+     * [Determine a size for your widget](https://developer.android.com/develop/ui/views/appwidgets/layouts#anatomy_determining_size).
+     * and identifying landscape and portrait sizes that your widget may appear on.
+     * 3. If your appWidget uses `sizeMode == Responsive`, you can set this to one of the sizes from
+     * the list that you provide when specifying the sizeMode.
+     */
+    fun setAppWidgetSize(size: DpSize)
+
+    /**
+     * Sets the state to be used for the test if your composable under test accesses it via
+     * `currentState<*>()` or `LocalState.current`.
+     *
+     * Default state is `null`. Note: This should be called before calling [provideComposable],
+     * updates to the state after providing content has no effect. This matches the appWidget
+     * behavior where you need to call `update` on the widget for state changes to take effect.
+     *
+     * @param state the state to be used for testing the composable.
+     * @param T type of state used in your [GlanceStateDefinition] e.g. `Preferences` if your state
+     *          definition is `GlanceStateDefinition<Preferences>`
+     */
+    fun <T> setState(state: T)
+
+    /**
+     * Sets the context to be used for the test.
+     *
+     * It is optional to call this method. However, you must set this if your composable needs
+     * access to `LocalContext`. You may need to use a Android unit test framework such as
+     * [Robolectric](https://github.com/robolectric/robolectric) to get the context.
+     *
+     * Note: This should be called before calling [provideComposable], updates to the state after
+     * providing content has no effect
+     */
+    fun setContext(context: Context)
+
+    /**
+     * Sets the Glance composable function to be tested. Each unit test should test a composable in
+     * isolation and assume specific state as input. Prefer keeping composables side-effects free.
+     * Perform any state changes needed for the test before calling [provideComposable] or
+     * [runGlanceAppWidgetUnitTest].
+     *
+     * @param composable the composable function under test
+     */
+    fun provideComposable(composable: @Composable () -> Unit)
+
+    /**
+     * Wait until all recompositions are calculated. For example if you have `LaunchedEffect` with
+     * delays in your composable.
+     */
+    fun awaitIdle()
+}
+
+/**
+ * Provides default values for various properties used in the Glance appWidget unit tests.
+ */
+object GlanceAppWidgetUnitTestDefaults {
+    /**
+     * [GlanceId] that can be assumed for state updates testing a Glance composable in isolation.
+     */
+    fun glanceId(): GlanceId = AppWidgetId(1)
+
+    /**
+     * Default size of the appWidget assumed in the unit tests. To override the size, use the
+     * [GlanceAppWidgetUnitTest.setAppWidgetSize] function.
+     *
+     * The default `349.dp, 455.dp` is that of a 5x4 widget in Pixel 4 portrait mode.
+     */
+    fun size(): DpSize = DpSize(height = 349.dp, width = 455.dp)
+
+    /**
+     * Default category of the appWidget assumed in the unit tests.
+     *
+     * The default is `WIDGET_CATEGORY_HOME_SCREEN`
+     */
+    fun hostCategory(): Int = AppWidgetProviderInfo.WIDGET_CATEGORY_HOME_SCREEN
+}
diff --git a/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironment.kt b/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironment.kt
new file mode 100644
index 0000000..674c8c5
--- /dev/null
+++ b/glance/glance-appwidget-testing/src/main/java/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironment.kt
@@ -0,0 +1,192 @@
+/*
+ * Copyright 2023 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.glance.appwidget.testing.unit
+
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.os.Bundle
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Composition
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.MonotonicFrameClock
+import androidx.compose.runtime.Recomposer
+import androidx.compose.ui.unit.DpSize
+import androidx.glance.Applier
+import androidx.glance.LocalContext
+import androidx.glance.LocalGlanceId
+import androidx.glance.LocalSize
+import androidx.glance.LocalState
+import androidx.glance.appwidget.LocalAppWidgetOptions
+import androidx.glance.appwidget.RemoteViewsRoot
+import androidx.glance.session.globalSnapshotMonitor
+import androidx.glance.testing.GlanceNodeAssertion
+import androidx.glance.testing.GlanceNodeMatcher
+import androidx.glance.testing.TestContext
+import androidx.glance.testing.unit.GlanceMappedNode
+import androidx.glance.testing.unit.MappedNode
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+
+internal val DEFAULT_TIMEOUT = 10.seconds
+
+/**
+ * An implementation of [GlanceAppWidgetUnitTest] that provides APIs to run composition for
+ * appwidget-specific Glance composable content.
+ */
+internal class GlanceAppWidgetUnitTestEnvironment(
+    private val timeout: Duration
+) : GlanceAppWidgetUnitTest {
+    private var testContext = TestContext<MappedNode, GlanceMappedNode>()
+    private var testScope = TestScope()
+
+    // Data for composition locals
+    private var context: Context? = null
+    private val fakeGlanceID = GlanceAppWidgetUnitTestDefaults.glanceId()
+    private var size: DpSize = GlanceAppWidgetUnitTestDefaults.size()
+    private var state: Any? = null
+
+    private val root = RemoteViewsRoot(10)
+
+    private lateinit var recomposer: Recomposer
+    private lateinit var composition: Composition
+
+    fun runTest(block: GlanceAppWidgetUnitTest.() -> Unit) = testScope.runTest(timeout) {
+        var snapshotMonitor: Job? = null
+        try {
+            // GlobalSnapshotManager.ensureStarted() uses Dispatcher.Default, so using
+            // globalSnapshotMonitor instead to be able to use test dispatcher instead.
+            snapshotMonitor = launch { globalSnapshotMonitor() }
+            val applier = Applier(root)
+            recomposer = Recomposer(testScope.coroutineContext)
+            composition = Composition(applier, recomposer)
+            block()
+        } finally {
+            composition.dispose()
+            snapshotMonitor?.cancel()
+            recomposer.cancel()
+            recomposer.join()
+        }
+    }
+
+    // Among the appWidgetOptions available, size related options shouldn't generally be necessary
+    // for developers to look up - the LocalSize composition local should suffice. So, currently, we
+    // only initialize host category.
+    private val appWidgetOptions = Bundle().apply {
+        putInt(
+            AppWidgetManager.OPTION_APPWIDGET_HOST_CATEGORY,
+            GlanceAppWidgetUnitTestDefaults.hostCategory()
+        )
+    }
+
+    override fun provideComposable(composable: @Composable () -> Unit) {
+        check(testContext.rootGlanceNode == null) {
+            "provideComposable can only be called once"
+        }
+
+        testScope.launch {
+            var compositionLocals = arrayOf(
+                LocalGlanceId provides fakeGlanceID,
+                LocalState provides state,
+                LocalAppWidgetOptions provides appWidgetOptions,
+                LocalSize provides size
+            )
+            context?.let {
+                compositionLocals = compositionLocals.plus(LocalContext provides it)
+            }
+
+            composition.setContent {
+                CompositionLocalProvider(
+                    values = compositionLocals,
+                    content = composable,
+                )
+            }
+
+            launch(currentCoroutineContext() + TestFrameClock()) {
+                recomposer.runRecomposeAndApplyChanges()
+            }
+
+            launch {
+                recomposer.currentState.collect { curState ->
+                    when (curState) {
+                        Recomposer.State.Idle -> {
+                            testContext.rootGlanceNode = GlanceMappedNode(
+                                emittable = root.copy()
+                            )
+                        }
+
+                        Recomposer.State.ShutDown -> {
+                            cancel()
+                        }
+
+                        else -> {}
+                    }
+                }
+            }
+        }
+    }
+
+    override fun awaitIdle() {
+        testScope.testScheduler.advanceUntilIdle()
+    }
+
+    override fun onNode(
+        matcher: GlanceNodeMatcher<MappedNode>
+    ): GlanceNodeAssertion<MappedNode, GlanceMappedNode> {
+        // Always let all the enqueued tasks finish before inspecting the tree.
+        testScope.testScheduler.runCurrent()
+        // Calling onNode resets the previously matched nodes and starts a new matching chain.
+        testContext.reset()
+        // Delegates matching to the next assertion.
+        return GlanceNodeAssertion(matcher, testContext)
+    }
+
+    override fun setAppWidgetSize(size: DpSize) {
+        check(testContext.rootGlanceNode == null) {
+            "setApWidgetSize should be called before calling provideComposable"
+        }
+        this.size = size
+    }
+
+    override fun <T> setState(state: T) {
+        check(testContext.rootGlanceNode == null) {
+            "setState should be called before calling provideComposable"
+        }
+        this.state = state
+    }
+
+    override fun setContext(context: Context) {
+        check(testContext.rootGlanceNode == null) {
+            "setContext should be called before calling provideComposable"
+        }
+        this.context = context
+    }
+
+    /**
+     * Test clock that sends all frames immediately.
+     */
+    // Same as TestUtils.TestFrameClock used in Glance unit tests.
+    private class TestFrameClock : MonotonicFrameClock {
+        override suspend fun <R> withFrameNanos(onFrame: (frameTimeNanos: Long) -> R) =
+            onFrame(System.currentTimeMillis())
+    }
+}
diff --git a/glance/glance-appwidget-testing/src/test/AndroidManifest.xml b/glance/glance-appwidget-testing/src/test/AndroidManifest.xml
new file mode 100644
index 0000000..f125c7b
--- /dev/null
+++ b/glance/glance-appwidget-testing/src/test/AndroidManifest.xml
@@ -0,0 +1,19 @@
+<!--
+  Copyright 2023 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.
+  -->
+
+<manifest>
+    <application/>
+</manifest>
\ No newline at end of file
diff --git a/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironmentRobolectricTest.kt b/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironmentRobolectricTest.kt
new file mode 100644
index 0000000..514826d
--- /dev/null
+++ b/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironmentRobolectricTest.kt
@@ -0,0 +1,73 @@
+/*
+ * Copyright 2023 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.glance.appwidget.testing.unit
+
+import android.content.Context
+import androidx.compose.runtime.Composable
+import androidx.glance.GlanceModifier
+import androidx.glance.LocalContext
+import androidx.glance.appwidget.testing.test.R
+import androidx.glance.layout.Column
+import androidx.glance.semantics.semantics
+import androidx.glance.semantics.testTag
+import androidx.glance.testing.unit.hasTestTag
+import androidx.glance.testing.unit.hasText
+import androidx.glance.text.Text
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [33])
+/**
+ * Holds tests that use Robolectric for providing application resources and context.
+ */
+class GlanceAppWidgetUnitTestEnvironmentRobolectricTest {
+    private lateinit var context: Context
+
+    @Before
+    fun setUp() {
+        context = ApplicationProvider.getApplicationContext()
+    }
+
+    @Test
+    fun runTest_localContextRead() = runGlanceAppWidgetUnitTest {
+        setContext(context)
+
+        provideComposable {
+            ComposableReadingLocalContext()
+        }
+
+        onNode(hasTestTag("test-tag"))
+            .assert(hasText("Test string: MyTest"))
+    }
+
+    @Composable
+    fun ComposableReadingLocalContext() {
+        val context = LocalContext.current
+
+        Column {
+            Text(
+                text = "Test string: ${context.getString(R.string.glance_test_string)}",
+                modifier = GlanceModifier.semantics { testTag = "test-tag" }
+            )
+        }
+    }
+}
diff --git a/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironmentTest.kt b/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironmentTest.kt
new file mode 100644
index 0000000..a1449d8
--- /dev/null
+++ b/glance/glance-appwidget-testing/src/test/kotlin/androidx/glance/appwidget/testing/unit/GlanceAppWidgetUnitTestEnvironmentTest.kt
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2023 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.glance.appwidget.testing.unit
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.preferencesOf
+import androidx.glance.GlanceModifier
+import androidx.glance.Image
+import androidx.glance.ImageProvider
+import androidx.glance.LocalSize
+import androidx.glance.appwidget.ImageProvider
+import androidx.glance.appwidget.testing.test.R
+import androidx.glance.currentState
+import androidx.glance.layout.Column
+import androidx.glance.layout.Spacer
+import androidx.glance.semantics.semantics
+import androidx.glance.semantics.testTag
+import androidx.glance.testing.unit.hasTestTag
+import androidx.glance.testing.unit.hasText
+import androidx.glance.text.Text
+import kotlinx.coroutines.delay
+import org.junit.Test
+
+// In this test we aren't specifically testing anything bound to SDK, so we can run it without
+// android unit test runners such as Robolectric.
+class GlanceAppWidgetUnitTestEnvironmentTest {
+    @Test
+    fun runTest_localSizeRead() = runGlanceAppWidgetUnitTest {
+        setAppWidgetSize(DpSize(width = 120.dp, height = 200.dp))
+
+        provideComposable {
+            ComposableReadingLocalSize()
+        }
+
+        onNode(hasText("120.0 dp x 200.0 dp")).assertExists()
+    }
+
+    @Composable
+    fun ComposableReadingLocalSize() {
+        val size = LocalSize.current
+        Column {
+            Text(text = "${size.width.value} dp x ${size.height.value} dp")
+            Spacer()
+            Image(
+                provider = ImageProvider(R.drawable.glance_test_android),
+                contentDescription = "test-image",
+            )
+        }
+    }
+
+    @Test
+    fun runTest_currentStateRead() = runGlanceAppWidgetUnitTest {
+        setState(preferencesOf(toggleKey to true))
+
+        provideComposable {
+            ComposableReadingState()
+        }
+
+        onNode(hasText("isToggled")).assertExists()
+    }
+
+    @Composable
+    fun ComposableReadingState() {
+        Column {
+            Text(text = "A text")
+            Spacer()
+            Text(text = getTitle(currentState<Preferences>()[toggleKey] == true))
+            Spacer()
+            Image(
+                provider = ImageProvider(R.drawable.glance_test_android),
+                contentDescription = "test-image",
+                modifier = GlanceModifier.semantics { testTag = "img" }
+            )
+        }
+    }
+
+    @Test
+    fun runTest_onNodeCalledMultipleTimes() = runGlanceAppWidgetUnitTest {
+        provideComposable {
+            Text(text = "abc")
+            Spacer()
+            Text(text = "xyz")
+        }
+
+        onNode(hasText("abc")).assertExists()
+        // test context reset and new filter matched onNode
+        onNode(hasText("xyz")).assertExists()
+        onNode(hasText("def")).assertDoesNotExist()
+    }
+
+    @Test
+    fun runTest_effect() = runGlanceAppWidgetUnitTest {
+        provideComposable {
+            var text by remember { mutableStateOf("initial") }
+
+            Text(text = text, modifier = GlanceModifier.semantics { testTag = "mutable-test" })
+            Spacer()
+            Text(text = "xyz")
+
+            LaunchedEffect(Unit) {
+                text = "changed"
+            }
+        }
+
+        onNode(hasTestTag("mutable-test")).assert(hasText("changed"))
+    }
+
+    @Test
+    fun runTest_effectWithDelay() = runGlanceAppWidgetUnitTest {
+        provideComposable {
+            var text by remember { mutableStateOf("initial") }
+
+            Text(text = text, modifier = GlanceModifier.semantics { testTag = "mutable-test" })
+            Spacer()
+            Text(text = "xyz")
+
+            LaunchedEffect(Unit) {
+                delay(100L)
+                text = "changed"
+            }
+        }
+
+        awaitIdle() // Since the launched effect has a delay.
+        onNode(hasTestTag("mutable-test")).assert(hasText("changed"))
+    }
+
+    @Test
+    fun runTest_effectWithDelayWithoutAdvancing() = runGlanceAppWidgetUnitTest {
+        provideComposable {
+            var text by remember { mutableStateOf("initial") }
+
+            Text(text = text, modifier = GlanceModifier.semantics { testTag = "mutable-test" })
+            Spacer()
+            Text(text = "xyz")
+
+            LaunchedEffect(Unit) {
+                delay(100L)
+                text = "changed"
+            }
+        }
+
+        onNode(hasTestTag("mutable-test")).assert(hasText("initial"))
+    }
+}
+
+private val toggleKey = booleanPreferencesKey("title_toggled_key")
+private fun getTitle(toggled: Boolean) = if (toggled) "isToggled" else "notToggled"
diff --git a/glance/glance-appwidget-testing/src/test/res/drawable/glance_test_android.xml b/glance/glance-appwidget-testing/src/test/res/drawable/glance_test_android.xml
new file mode 100644
index 0000000..49a3142
--- /dev/null
+++ b/glance/glance-appwidget-testing/src/test/res/drawable/glance_test_android.xml
@@ -0,0 +1,21 @@
+<!--
+  Copyright 2023 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.
+  -->
+
+<vector android:alpha="0.9" android:height="24dp"
+    android:viewportHeight="24" android:viewportWidth="24"
+    android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
+    <path android:fillColor="@android:color/white" android:pathData="M17.6,9.48l1.84,-3.18c0.16,-0.31 0.04,-0.69 -0.26,-0.85c-0.29,-0.15 -0.65,-0.06 -0.83,0.22l-1.88,3.24c-2.86,-1.21 -6.08,-1.21 -8.94,0L5.65,5.67c-0.19,-0.29 -0.58,-0.38 -0.87,-0.2C4.5,5.65 4.41,6.01 4.56,6.3L6.4,9.48C3.3,11.25 1.28,14.44 1,18h22C22.72,14.44 20.7,11.25 17.6,9.48zM7,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25S8.25,13.31 8.25,14C8.25,14.69 7.69,15.25 7,15.25zM17,15.25c-0.69,0 -1.25,-0.56 -1.25,-1.25c0,-0.69 0.56,-1.25 1.25,-1.25s1.25,0.56 1.25,1.25C18.25,14.69 17.69,15.25 17,15.25z"/>
+</vector>
diff --git a/glance/glance-appwidget-testing/src/test/res/values/strings.xml b/glance/glance-appwidget-testing/src/test/res/values/strings.xml
new file mode 100644
index 0000000..88a0850
--- /dev/null
+++ b/glance/glance-appwidget-testing/src/test/res/values/strings.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?><!--
+  Copyright 2023 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.
+  -->
+
+<resources>
+    <string name="glance_test_string">MyTest</string>
+</resources>
\ No newline at end of file
diff --git a/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/GlanceAppWidget.kt b/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/GlanceAppWidget.kt
index f667413..a1d03ba 100644
--- a/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/GlanceAppWidget.kt
+++ b/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/GlanceAppWidget.kt
@@ -23,6 +23,8 @@
 import android.util.Log
 import android.widget.RemoteViews
 import androidx.annotation.LayoutRes
+import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope
 import androidx.compose.runtime.Composable
 import androidx.glance.GlanceComposable
 import androidx.glance.GlanceId
@@ -194,7 +196,8 @@
     }
 }
 
-internal data class AppWidgetId(val appWidgetId: Int) : GlanceId
+@RestrictTo(Scope.LIBRARY_GROUP)
+data class AppWidgetId(val appWidgetId: Int) : GlanceId
 
 /** Update all App Widgets managed by the [GlanceAppWidget] class. */
 suspend fun GlanceAppWidget.updateAll(@Suppress("ContextFirst") context: Context) {
diff --git a/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/RemoteViewsRoot.kt b/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/RemoteViewsRoot.kt
index 1428527..df0e178 100644
--- a/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/RemoteViewsRoot.kt
+++ b/glance/glance-appwidget/src/main/java/androidx/glance/appwidget/RemoteViewsRoot.kt
@@ -16,6 +16,8 @@
 
 package androidx.glance.appwidget
 
+import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope
 import androidx.glance.Emittable
 import androidx.glance.EmittableWithChildren
 import androidx.glance.GlanceModifier
@@ -24,7 +26,8 @@
  * Root view, with a maximum depth. No default value is specified, as the exact value depends on
  * specific circumstances.
  */
-internal class RemoteViewsRoot(private val maxDepth: Int) : EmittableWithChildren(maxDepth) {
+@RestrictTo(Scope.LIBRARY_GROUP)
+ class RemoteViewsRoot(private val maxDepth: Int) : EmittableWithChildren(maxDepth) {
     override var modifier: GlanceModifier = GlanceModifier
     override fun copy(): Emittable = RemoteViewsRoot(maxDepth).also {
         it.modifier = modifier
diff --git a/glance/glance-testing/api/current.txt b/glance/glance-testing/api/current.txt
index 51dd245..e46a742 100644
--- a/glance/glance-testing/api/current.txt
+++ b/glance/glance-testing/api/current.txt
@@ -14,6 +14,10 @@
     method public androidx.glance.testing.GlanceNodeAssertion<R,T> assertExists();
   }
 
+  public interface GlanceNodeAssertionsProvider<R, T extends androidx.glance.testing.GlanceNode<R>> {
+    method public androidx.glance.testing.GlanceNodeAssertion<R,T> onNode(androidx.glance.testing.GlanceNodeMatcher<R> matcher);
+  }
+
   public final class GlanceNodeMatcher<R> {
     ctor public GlanceNodeMatcher(String description, kotlin.jvm.functions.Function1<? super androidx.glance.testing.GlanceNode<R>,java.lang.Boolean> matcher);
     method public boolean matches(androidx.glance.testing.GlanceNode<R> node);
diff --git a/glance/glance-testing/api/restricted_current.txt b/glance/glance-testing/api/restricted_current.txt
index 51dd245..e46a742 100644
--- a/glance/glance-testing/api/restricted_current.txt
+++ b/glance/glance-testing/api/restricted_current.txt
@@ -14,6 +14,10 @@
     method public androidx.glance.testing.GlanceNodeAssertion<R,T> assertExists();
   }
 
+  public interface GlanceNodeAssertionsProvider<R, T extends androidx.glance.testing.GlanceNode<R>> {
+    method public androidx.glance.testing.GlanceNodeAssertion<R,T> onNode(androidx.glance.testing.GlanceNodeMatcher<R> matcher);
+  }
+
   public final class GlanceNodeMatcher<R> {
     ctor public GlanceNodeMatcher(String description, kotlin.jvm.functions.Function1<? super androidx.glance.testing.GlanceNode<R>,java.lang.Boolean> matcher);
     method public boolean matches(androidx.glance.testing.GlanceNode<R> node);
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeAssertionsProvider.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeAssertionsProvider.kt
new file mode 100644
index 0000000..624b529
--- /dev/null
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/GlanceNodeAssertionsProvider.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2023 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.glance.testing
+
+/**
+ * Provides an entry point into testing exposing methods to find glance nodes
+ */
+// Equivalent to "androidx.compose.ui.test.SemanticsNodeInteractionsProvider" from compose.
+interface GlanceNodeAssertionsProvider<R, T : GlanceNode<R>> {
+    /**
+     * Finds a Glance node that matches the given condition.
+     *
+     * Any subsequent operation on its result will expect exactly one element found and will throw
+     * [AssertionError] if none or more than one element is found.
+     *
+     * @param matcher Matcher used for filtering
+     */
+    fun onNode(matcher: GlanceNodeMatcher<R>): GlanceNodeAssertion<R, T>
+}
diff --git a/glance/glance-testing/src/main/java/androidx/glance/testing/TestContext.kt b/glance/glance-testing/src/main/java/androidx/glance/testing/TestContext.kt
index 1399349..1ab76a3 100644
--- a/glance/glance-testing/src/main/java/androidx/glance/testing/TestContext.kt
+++ b/glance/glance-testing/src/main/java/androidx/glance/testing/TestContext.kt
@@ -23,6 +23,13 @@
  */
 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
 class TestContext<R, T : GlanceNode<R>> {
+    /**
+     * To be called on every onNode to restart matching and clear cache.
+     */
+    fun reset() {
+        cachedMatchedNodes = emptyList()
+    }
+
     var rootGlanceNode: T? = null
     var cachedMatchedNodes: List<GlanceNode<R>> = emptyList()
 }
diff --git a/glance/glance/src/main/java/androidx/glance/session/GlobalSnapshotManager.kt b/glance/glance/src/main/java/androidx/glance/session/GlobalSnapshotManager.kt
index cebf899..d5f6d8d 100644
--- a/glance/glance/src/main/java/androidx/glance/session/GlobalSnapshotManager.kt
+++ b/glance/glance/src/main/java/androidx/glance/session/GlobalSnapshotManager.kt
@@ -17,6 +17,7 @@
 package androidx.glance.session
 
 import androidx.annotation.RestrictTo
+import androidx.annotation.RestrictTo.Scope
 import androidx.compose.runtime.snapshots.Snapshot
 import java.util.concurrent.atomic.AtomicBoolean
 import kotlinx.coroutines.CoroutineScope
@@ -33,7 +34,7 @@
  * state changes). These will be sent on Dispatchers.Default.
  * This is based on [androidx.compose.ui.platform.GlobalSnapshotManager].
  */
-@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
+@RestrictTo(Scope.LIBRARY_GROUP)
 object GlobalSnapshotManager {
     private val started = AtomicBoolean(false)
     private val sent = AtomicBoolean(false)
@@ -59,7 +60,8 @@
 /**
  * Monitors global snapshot state writes and sends apply notifications.
  */
-internal suspend fun globalSnapshotMonitor() {
+@RestrictTo(Scope.LIBRARY_GROUP)
+suspend fun globalSnapshotMonitor() {
     val channel = Channel<Unit>(1)
     val sent = AtomicBoolean(false)
     val observerHandle = Snapshot.registerGlobalWriteObserver {
diff --git a/settings.gradle b/settings.gradle
index d897f49..7f6500a 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -763,7 +763,9 @@
 includeProject(":glance:glance-appwidget", [BuildType.GLANCE])
 includeProject(":glance:glance-appwidget-preview", [BuildType.GLANCE])
 includeProject(":glance:glance-appwidget-proto", [BuildType.GLANCE])
+includeProject(":glance:glance-appwidget-testing", [BuildType.GLANCE])
 includeProject(":glance:glance-appwidget:glance-appwidget-samples", "glance/glance-appwidget/samples", [BuildType.GLANCE])
+includeProject(":glance:glance-appwidget-testing:glance-appwidget-testing-samples", "glance/glance-appwidget-testing/samples", [BuildType.GLANCE])
 includeProject(":glance:glance-appwidget:integration-tests:demos", [BuildType.GLANCE])
 includeProject(":glance:glance-appwidget:integration-tests:macrobenchmark", [BuildType.GLANCE])
 includeProject(":glance:glance-appwidget:integration-tests:macrobenchmark-target", [BuildType.GLANCE])