Create starlark testutils crate

This CL introduces a mechanism to create a fake test object that supports EvalContext.
This allows us to write tests for crates that depend on an EvalContext
without touching any GN code.

Bug: 528225104
Change-Id: I637f984205e5fb7910f42dc15f0a80d16a6a6964
Reviewed-on: https://gn-review.googlesource.com/c/gn/+/23540
Reviewed-by: Cole Faust <colefaust@google.com>
Commit-Queue: Matt Stark <msta@google.com>
Reviewed-by: Philipp Wollermann <philwo@google.com>
Reviewed-by: Matt Stark <msta@google.com>
diff --git a/src/gn/starlark/Cargo.lock b/src/gn/starlark/Cargo.lock
index f68f352..0444f96 100644
--- a/src/gn/starlark/Cargo.lock
+++ b/src/gn/starlark/Cargo.lock
@@ -1602,6 +1602,17 @@
 ]
 
 [[package]]
+name = "testutils"
+version = "0.1.0"
+dependencies = [
+ "allocative",
+ "attr",
+ "starlark",
+ "starlark_derive",
+ "types",
+]
+
+[[package]]
 name = "textwrap"
 version = "0.11.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/src/gn/starlark/Cargo.toml b/src/gn/starlark/Cargo.toml
index 9d2a06d..dc9b615 100644
--- a/src/gn/starlark/Cargo.toml
+++ b/src/gn/starlark/Cargo.toml
@@ -15,6 +15,7 @@
     ".",
     "crates/attr",
     "crates/ffi",
+    "crates/testutils",
     "crates/types",
 ]
 
diff --git a/src/gn/starlark/crates/testutils/Cargo.toml b/src/gn/starlark/crates/testutils/Cargo.toml
new file mode 100644
index 0000000..d307753
--- /dev/null
+++ b/src/gn/starlark/crates/testutils/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "testutils"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+test = false
+doctest = false
+
+[dependencies]
+allocative = { workspace = true }
+attr = { path = "../attr" }
+starlark = { workspace = true }
+starlark_derive = { workspace = true }
+types = { path = "../types" }
diff --git a/src/gn/starlark/crates/testutils/src/assert.rs b/src/gn/starlark/crates/testutils/src/assert.rs
new file mode 100644
index 0000000..9bbb8f3
--- /dev/null
+++ b/src/gn/starlark/crates/testutils/src/assert.rs
@@ -0,0 +1,120 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use starlark::values::UnpackValue;
+use types::{EvaluatorContextExt, UnpackedOwnedValue};
+
+use crate::FakeEvalContext;
+
+/// A simple wrapper around starlark::Assert that provides fake evaluation
+/// contexts.
+pub struct Assert {
+    assert: starlark::assert::Assert<'static>,
+    context: Box<FakeEvalContext>,
+}
+
+impl Default for Assert {
+    fn default() -> Self {
+        Self::new(FakeEvalContext::default())
+    }
+}
+
+impl Assert {
+    /// Creates a new `Assert` helper instance with the given context.
+    pub fn new(context: FakeEvalContext) -> Self {
+        let mut assert = starlark::assert::Assert::new();
+        // By default, starlark runs the code 3 times (with always GC, auto GC, and
+        // disabled GC) to check for GC bugs. Because the EvalContext can be
+        // mutated by the evaluator, running 3 times could cause state mutations
+        // to happen three times, messing with tests. Calling `always_gc()`
+        // forces the framework to run the code only once (specifically,
+        // under the "always GC" configuration), ensuring state is mutated only once.
+        assert.always_gc();
+        let mut context = Box::new(context);
+        let context_ptr = &mut *context as *mut FakeEvalContext;
+
+        assert.setup_eval(move |eval| {
+            // Safety: The context is owned by Assert, which outlives the evaluator run.
+            // Since all evaluation methods on Assert require `&mut self`, this guarantees
+            // exclusive access to `context` when the evaluator runs, so dereferencing
+            // this pointer is safe and does not alias.
+            let context_mut = unsafe { &mut *context_ptr };
+            eval.set_context(context_mut);
+        });
+
+        Self { assert, context }
+    }
+
+    /// Returns a read-only reference to the fake evaluation context.
+    pub fn context(&self) -> &FakeEvalContext {
+        &self.context
+    }
+
+    /// Asserts that the result of evaluating code is equal to expected.
+    #[track_caller]
+    pub fn eq<T>(&mut self, code: &str, expected: T)
+    where
+        T: PartialEq + std::fmt::Debug + for<'v> UnpackValue<'v> + 'static,
+    {
+        assert_eq!(*self.eval::<T>(code), expected);
+    }
+
+    /// Evaluates code and unpacks it to a given type.
+    #[track_caller]
+    pub fn eval<T>(&mut self, code: &str) -> UnpackedOwnedValue<T>
+    where
+        T: for<'v> UnpackValue<'v> + 'static,
+    {
+        let owned_val = self.assert.pass(code);
+        UnpackedOwnedValue::<T>::try_from(owned_val).unwrap()
+    }
+
+    /// Asserts that the two pieces of code produce something equivalent.
+    #[track_caller]
+    pub fn equivalent(&mut self, lhs_code: &str, rhs_code: &str) {
+        let lhs_val = self.assert.pass(lhs_code);
+        let rhs_val = self.assert.pass(rhs_code);
+        assert_eq!(lhs_val.value(), rhs_val.value());
+    }
+
+    // We explicitly implement `pass`, `fail`, and `fails` with `&mut self`
+    // signatures despite `Deref` existing. The inherited methods on
+    // `starlark::assert::Assert` only take `&self`, which would bypass the
+    // borrow checker and allow running the evaluator while holding an active
+    // context borrow (leading to UB). Exposing them as `&mut self` methods on
+    // the wrapper statically prevents this.
+
+    /// Evaluates code and returns the Starlark value.
+    #[track_caller]
+    pub fn pass(&mut self, code: &str) -> starlark::values::OwnedFrozenValue {
+        self.assert.pass(code)
+    }
+
+    /// Asserts that the code fails to evaluate with the expected error.
+    #[track_caller]
+    pub fn fail(&mut self, code: &str, expected_error: &str) -> starlark::Error {
+        self.assert.fail(code, expected_error)
+    }
+
+    /// Asserts that the code fails to evaluate with any of the expected errors.
+    #[track_caller]
+    pub fn fails(&mut self, code: &str, expected_errors: &[&str]) -> starlark::Error {
+        self.assert.fails(code, expected_errors)
+    }
+}
+
+// We implement deref to get for free all the methods on starlark::Assert.
+impl std::ops::Deref for Assert {
+    type Target = starlark::assert::Assert<'static>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.assert
+    }
+}
+
+impl std::ops::DerefMut for Assert {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.assert
+    }
+}
diff --git a/src/gn/starlark/crates/testutils/src/eval_context.rs b/src/gn/starlark/crates/testutils/src/eval_context.rs
new file mode 100644
index 0000000..0bb9752
--- /dev/null
+++ b/src/gn/starlark/crates/testutils/src/eval_context.rs
@@ -0,0 +1,112 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use attr::{Attr, EvalContext as AttrEvalContext, EvalContextAttrExt, Session as AttrSession};
+use starlark::{
+    values::{FrozenValue, ProvidesStaticType},
+    Result,
+};
+use types::{CtxState, Label, LabelRef, Package, PackageRef, PathResolver, Session};
+
+use crate::{FakeSession, FakeTarget, FakeTargetRef};
+
+/// A simple implementation of the evaluation context used in Starlark unit
+/// tests.
+#[derive(allocative::Allocative)]
+pub struct FakeEvalContext {
+    /// The current package being processed.
+    pub package: Package,
+    /// The current toolchain.
+    pub current_toolchain: Label,
+    /// The fake starlark session.
+    #[allocative(skip)]
+    pub session: FakeSession,
+    /// The fake path resolver.
+    #[allocative(skip)]
+    pub path_resolver: PathResolver,
+    /// The fake rule state.
+    #[allocative(skip)]
+    pub rule_state: CtxState<FakeTargetRef>,
+}
+
+unsafe impl<'v> ProvidesStaticType<'v> for FakeEvalContext {
+    type StaticType = Self;
+}
+
+impl Default for FakeEvalContext {
+    fn default() -> Self {
+        Self::new("//")
+    }
+}
+
+impl FakeEvalContext {
+    /// Creates a new `FakeEvalContext` for a given package name.
+    pub fn new(package: &str) -> Self {
+        let session = FakeSession::new();
+        let package_ref = PackageRef::new_for_testing(package).to_owned();
+        Self {
+            package: package_ref,
+            current_toolchain: session.default_toolchain.clone(),
+            session,
+            path_resolver: PathResolver::new_for_testing(),
+            rule_state: CtxState::new(FakeTargetRef::default()),
+        }
+    }
+}
+
+impl AttrEvalContext for FakeEvalContext {
+    type Session = FakeSession;
+
+    fn session(&self) -> &Self::Session {
+        &self.session
+    }
+
+    fn current_package(&self) -> &PackageRef {
+        &self.package
+    }
+
+    fn path_resolver(&self) -> &PathResolver {
+        &self.path_resolver
+    }
+
+    fn current_toolchain(&self) -> LabelRef<'_> {
+        self.current_toolchain.as_ref()
+    }
+
+    fn require_macro(&self) -> Result<()> {
+        Ok(())
+    }
+
+    fn require_bzl(&self) -> Result<()> {
+        Ok(())
+    }
+
+    fn require_rule_impl(&self) -> Result<&CtxState<<Self::Session as Session>::TargetRef>> {
+        Ok(&self.rule_state)
+    }
+
+    fn require_rule_impl_mut(
+        &mut self,
+    ) -> Result<&mut CtxState<<Self::Session as Session>::TargetRef>> {
+        Ok(&mut self.rule_state)
+    }
+}
+
+impl EvalContextAttrExt for FakeEvalContext {
+    fn create_starlark_target(
+        &self,
+        target_name: &str,
+        _rule: FrozenValue,
+        attrs: Vec<Attr>,
+    ) -> Result<<Self::Session as AttrSession>::TargetRef> {
+        let label = Label::new(self.package.clone(), target_name.to_owned());
+        let target = FakeTargetRef::new(FakeTarget {
+            outputs: vec![],
+            attrs,
+            ..Default::default()
+        });
+        self.session.insert_target(label, target.clone());
+        Ok(target)
+    }
+}
diff --git a/src/gn/starlark/crates/testutils/src/lib.rs b/src/gn/starlark/crates/testutils/src/lib.rs
new file mode 100644
index 0000000..52a4b4c
--- /dev/null
+++ b/src/gn/starlark/crates/testutils/src/lib.rs
@@ -0,0 +1,13 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+pub mod assert;
+pub mod eval_context;
+pub mod session;
+pub mod target;
+
+pub use assert::Assert;
+pub use eval_context::FakeEvalContext;
+pub use session::FakeSession;
+pub use target::{FakeTarget, FakeTargetRef};
diff --git a/src/gn/starlark/crates/testutils/src/session.rs b/src/gn/starlark/crates/testutils/src/session.rs
new file mode 100644
index 0000000..26d918e
--- /dev/null
+++ b/src/gn/starlark/crates/testutils/src/session.rs
@@ -0,0 +1,74 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use std::{collections::HashMap, sync::Mutex};
+
+use attr::Session;
+use types::{Label, LabelRef, PackageRef};
+
+use crate::FakeTargetRef;
+
+/// A fake implementation of the `Session` trait for testing.
+pub struct FakeSession {
+    /// The preconfigured default toolchain label.
+    pub default_toolchain: Label,
+    /// A map of fake targets populated for testing, indexed by (label,
+    /// toolchain).
+    pub targets: Mutex<HashMap<(Label, Label), FakeTargetRef>>,
+}
+
+impl Default for FakeSession {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
+impl FakeSession {
+    /// Creates a new `FakeSession` instance with empty targets and a
+    /// preconfigured default toolchain.
+    pub fn new() -> Self {
+        Self {
+            default_toolchain: Label::new(
+                PackageRef::root().to_owned(),
+                "default_toolchain".to_owned(),
+            ),
+            targets: Mutex::new(HashMap::new()),
+        }
+    }
+
+    /// Helper to insert a target under the default toolchain.
+    pub fn insert_target(&self, label: Label, target: FakeTargetRef) {
+        self.targets
+            .lock()
+            .unwrap()
+            .insert((label, self.default_toolchain.clone()), target);
+    }
+}
+
+impl Session for FakeSession {
+    type TargetRef = FakeTargetRef;
+
+    fn get_target(&self, label: LabelRef<'_>, current_toolchain: LabelRef<'_>) -> Self::TargetRef {
+        self.targets
+            .lock()
+            .unwrap()
+            .get(&(label.to_owned(), current_toolchain.to_owned()))
+            .cloned()
+            .unwrap_or_default()
+    }
+
+    fn register_dependency<'a>(
+        &self,
+        source: Self::TargetRef,
+        target: LabelRef<'a>,
+        toolchain: LabelRef<'a>,
+    ) {
+        source
+            .get()
+            .dependencies
+            .lock()
+            .unwrap()
+            .insert((target.to_owned(), toolchain.to_owned()));
+    }
+}
diff --git a/src/gn/starlark/crates/testutils/src/target.rs b/src/gn/starlark/crates/testutils/src/target.rs
new file mode 100644
index 0000000..a60a968
--- /dev/null
+++ b/src/gn/starlark/crates/testutils/src/target.rs
@@ -0,0 +1,90 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use std::{
+    collections::HashSet,
+    sync::{Arc, Mutex},
+};
+
+use allocative::Allocative;
+use attr::Attr;
+use starlark::{
+    starlark_simple_value,
+    values::{ProvidesStaticType, StarlarkValue},
+};
+use starlark_derive::{starlark_value, NoSerialize};
+use types::{File, Label, TargetRef};
+
+/// A fake target struct for testing.
+#[derive(Debug, Allocative, Default)]
+pub struct FakeTarget {
+    /// A list of fake files returned as outputs of the target.
+    pub outputs: Vec<File>,
+    /// A list of attributes.
+    pub attrs: Vec<Attr>,
+    /// Registered target dependencies.
+    #[allocative(skip)]
+    pub dependencies: Mutex<HashSet<(Label, Label)>>,
+}
+
+/// A reference to a fake target.
+#[derive(Debug, ProvidesStaticType, NoSerialize, Allocative, Clone)]
+pub struct FakeTargetRef(#[allocative(skip)] Arc<FakeTarget>);
+
+impl Default for FakeTargetRef {
+    fn default() -> Self {
+        Self::new(FakeTarget::default())
+    }
+}
+
+impl FakeTargetRef {
+    /// Creates a new `FakeTargetRef` containing the given `FakeTarget`.
+    pub fn new(target: FakeTarget) -> Self {
+        Self(Arc::new(target))
+    }
+
+    /// Returns a shared reference to the underlying target.
+    pub fn get(&self) -> &FakeTarget {
+        &self.0
+    }
+
+    /// Returns the registered dependencies of this target.
+    pub fn registered_deps(&self) -> HashSet<(Label, Label)> {
+        self.get().dependencies.lock().unwrap().clone()
+    }
+}
+
+impl PartialEq for FakeTargetRef {
+    fn eq(&self, other: &Self) -> bool {
+        Arc::ptr_eq(&self.0, &other.0)
+    }
+}
+impl Eq for FakeTargetRef {}
+
+impl std::hash::Hash for FakeTargetRef {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        Arc::as_ptr(&self.0).hash(state);
+    }
+}
+
+starlark_simple_value!(FakeTargetRef);
+
+impl std::fmt::Display for FakeTargetRef {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "FakeTargetRef")
+    }
+}
+
+#[starlark_value(type = "Target")]
+impl<'v> StarlarkValue<'v> for FakeTargetRef {}
+
+impl TargetRef for FakeTargetRef {
+    fn outputs(&self) -> Vec<File> {
+        self.get().outputs.clone()
+    }
+
+    fn target_out_dir(&self, prefix: &str, suffix: &str, _separator: &str) -> String {
+        format!("{prefix}$TOOLCHAIN/{suffix}$LABEL")
+    }
+}
diff --git a/src/gn/starlark/crates/types/src/eval_context.rs b/src/gn/starlark/crates/types/src/eval_context.rs
index 65a6cda..f23ae6c 100644
--- a/src/gn/starlark/crates/types/src/eval_context.rs
+++ b/src/gn/starlark/crates/types/src/eval_context.rs
@@ -61,6 +61,9 @@
 
     /// Returns a mutable reference to the evaluation context.
     fn context_mut<C: EvalContext>(&mut self) -> &mut C;
+
+    /// Sets the evaluation context on the evaluator.
+    fn set_context<C: EvalContext>(&mut self, context: &'a mut C);
 }
 
 impl<'v, 'a, 'e> EvaluatorContextExt<'v, 'a, 'e> for starlark::eval::Evaluator<'v, 'a, 'e> {
@@ -81,4 +84,9 @@
         debug_assert!(dyn_any.is::<C>(), "failed to downcast evaluator context");
         unsafe { dyn_any.downcast_mut::<C>().unwrap_unchecked() }
     }
+
+    #[inline]
+    fn set_context<C: EvalContext>(&mut self, context: &'a mut C) {
+        self.extra_mut = Some(context);
+    }
 }
diff --git a/src/gn/starlark/crates/types/src/lib.rs b/src/gn/starlark/crates/types/src/lib.rs
index c43dd5f..40e565f 100644
--- a/src/gn/starlark/crates/types/src/lib.rs
+++ b/src/gn/starlark/crates/types/src/lib.rs
@@ -13,6 +13,7 @@
 pub mod path_resolver;
 pub mod session;
 pub mod target_ref;
+pub mod unpacked_owned_value;
 pub mod util;
 
 pub use ctx_state::CtxState;
@@ -26,3 +27,4 @@
 pub use path_resolver::PathResolver;
 pub use session::Session;
 pub use target_ref::TargetRef;
+pub use unpacked_owned_value::UnpackedOwnedValue;
diff --git a/src/gn/starlark/crates/types/src/unpacked_owned_value.rs b/src/gn/starlark/crates/types/src/unpacked_owned_value.rs
new file mode 100644
index 0000000..5bb4ad6
--- /dev/null
+++ b/src/gn/starlark/crates/types/src/unpacked_owned_value.rs
@@ -0,0 +1,42 @@
+// Copyright 2026 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+use std::ops::Deref;
+
+use starlark::values::{FrozenHeapRef, OwnedFrozenValue, UnpackValue};
+
+/// A type-safe wrapper that holds an unpacked Starlark value and the
+/// FrozenHeapRef that keeps the underlying memory alive.
+pub struct UnpackedOwnedValue<T: 'static> {
+    value: T,
+    #[allow(dead_code)] // Keeps the heap alive.
+    heap: FrozenHeapRef,
+}
+
+impl<T: 'static> Deref for UnpackedOwnedValue<T> {
+    type Target = T;
+
+    fn deref(&self) -> &Self::Target {
+        &self.value
+    }
+}
+
+impl<T> TryFrom<OwnedFrozenValue> for UnpackedOwnedValue<T>
+where
+    T: for<'v> UnpackValue<'v> + 'static,
+{
+    type Error = starlark::Error;
+
+    fn try_from(val: OwnedFrozenValue) -> Result<Self, Self::Error> {
+        let heap = val.owner().clone();
+        // Safety: We extract the raw FrozenValue from OwnedFrozenValue. This is safe
+        // because we clone the heap ref and store it in UnpackedOwnedValue,
+        // ensuring the heap outlives T.
+        let value = unsafe { val.unchecked_frozen_value() }.to_value();
+        Ok(Self {
+            value: T::unpack_value_err(value)?,
+            heap,
+        })
+    }
+}