[PATCH v2 7/8] tests/cooker: add TinfoilTests for run_prepared_task

AdrianF <[email protected]>
Newsgroups org.openembedded.lists.bitbake-devel
Message-ID <[email protected]>
From: Adrian Freihofer <[email protected]>

Add TinfoilTests, built on _BitbakeSubprocessTestCase, covering
'tinfoil: add a prepared task runner'. Each test spawns a subprocess to
isolate tinfoil's server lifecycle.

TestEquivHash is needed because the noop siggen's invalidate_task()
removes the base stamp path instead of the task-specific one, making
force=True a no-op otherwise.

test_run_prepared_task_recipecache_self_heals additionally proves that
run_prepared_task() (like "bitbake -b" before it) only wipes and
repopulates the recipe cache for the one recipe it touches as a
transient footprint: a subsequent normal, full parse restores the
complete recipe set again.

Lives in cooker.py rather than runqueue.py since it tests Tinfoil's
Python API, not CLI-level runqueue behaviour.

AI-Generated: Uses GitHub Copilot

Signed-off-by: Adrian Freihofer <[email protected]>
---
 lib/bb/tests/cooker.py | 244 +++++++++++++++++++++++++++++++++++++++++
 1 file changed, 244 insertions(+)

diff --git a/lib/bb/tests/cooker.py b/lib/bb/tests/cooker.py
index c32694cc5..db4104d40 100644
--- a/lib/bb/tests/cooker.py
+++ b/lib/bb/tests/cooker.py
@@ -63,6 +63,250 @@ class _BitbakeSubprocessTestCase(unittest.TestCase):
                 self._shutdown(builddir)
 
 
+class TinfoilTests(_BitbakeSubprocessTestCase):
+    """Tests for the Tinfoil API that require a running bitbake server."""
+
+    # Library directory containing bb.tinfoil
+    _bblib = os.path.realpath(os.path.join(os.path.dirname(__file__), '..', '..'))
+    # runqueue-tests BBPATH (provides the simple a1/b1/... test recipes)
+    _runqueuetests = os.path.realpath(os.path.join(os.path.dirname(__file__), 'runqueue-tests'))
+
+    failing_recipe = """\
+python do_install() {
+    bb.fatal("deliberate failure")
+}
+addtask install
+"""
+
+    # do_install genuinely depends on do_compile's output, unlike the
+    # a1 fixture recipe's dummy stamptask() tasks.
+    dependent_recipe = """\
+python do_compile() {
+    with open(d.expand("${TOPDIR}/compiled"), "w") as f:
+        f.write("compiled")
+}
+addtask compile
+
+python do_install() {
+    if not os.path.exists(d.expand("${TOPDIR}/compiled")):
+        bb.fatal("do_install ran without do_compile's output being present")
+}
+addtask install after do_compile
+"""
+
+    # Same contract as dependent_recipe, but with shell tasks, since a real
+    # recipe's do_install is usually shell (e.g. install/cp under pseudo)
+    # rather than a python function.
+    shell_dependent_recipe = """\
+do_compile() {
+    echo compiled > "${TOPDIR}/compiled"
+}
+addtask compile
+
+do_install() {
+    if [ ! -e "${TOPDIR}/compiled" ]; then
+        echo "do_install ran without do_compile's output being present" >&2
+        exit 1
+    fi
+}
+addtask install after do_compile
+"""
+
+    def _make_env(self, builddir, extra=None):
+        env = os.environ.copy()
+        env['PYTHONPATH'] = self._bblib + (':' + env['PYTHONPATH'] if 'PYTHONPATH' in env else '')
+        env['BBPATH'] = self._runqueuetests
+        env['BB_ENV_PASSTHROUGH_ADDITIONS'] = 'SSTATEVALID SLOWTASKS TOPDIR BB_HASHSERVE BB_SIGNATURE_HANDLER EXTRA_BBFILES'
+        env['SSTATEVALID'] = ''
+        env['SLOWTASKS'] = ''
+        env['TOPDIR'] = builddir
+        # TestEquivHash creates taint files so that force=True actually
+        # invalidates the task hash; the default noop siggen cannot do this.
+        env['BB_HASHSERVE'] = 'auto'
+        env['BB_SIGNATURE_HANDLER'] = 'TestEquivHash'
+        if extra:
+            env.update(extra)
+        return env
+
+    def _run_script(self, builddir, script, extra=None):
+        """Run script in a subprocess to isolate tinfoil's server lifecycle."""
+        return self._run_subprocess([sys.executable, '-c', script],
+                                    self._make_env(builddir, extra), builddir)
+
+    def _read_tasklog(self, builddir, cleanup=True):
+        tasklog = os.path.join(builddir, 'task.log')
+        tasks = []
+        if os.path.exists(tasklog):
+            with open(tasklog) as f:
+                tasks = [line.rstrip() for line in f]
+            if cleanup:
+                os.remove(tasklog)
+        return tasks
+
+    def test_run_prepared_task(self):
+        """tinfoil.run_prepared_task() reruns one task without resolving deps.
+
+        Uses do_install since that's the real devtool ide-sdk scenario: it
+        needs pseudo and so must run via bitbake, unlike the compile step which
+        the IDE invokes directly (e.g. via cmake/meson).
+
+        Builds a1 completely so all stamps/hashes are valid, then calls
+        run_prepared_task('a1', 'install') through the Python API and verifies
+        that only do_install re-runs while its intra-recipe predecessors
+        (fetch, unpack, patch, prepare_recipe_sysroot, configure, compile) are
+        skipped.
+        """
+        # The script runs inside a subprocess so that tinfoil's server
+        # lifecycle and environment modifications are isolated.
+        script = """
+import os, sys
+import bb.tinfoil
+
+builddir = os.environ['TOPDIR']
+tasklog  = os.path.join(builddir, 'task.log')
+
+with bb.tinfoil.Tinfoil() as tinfoil:
+    tinfoil.prepare(quiet=2)
+    # Full build so all stamps and hashes are valid.
+    tinfoil.build_targets(['a1'])
+    # Clear the log so only the run_prepared_task() entries are counted.
+    if os.path.exists(tasklog):
+        os.remove(tasklog)
+    # run_prepared_task() sets force=True (taint) and calls build_file_sync
+    # with the recipe file resolved via get_recipe_file(), bypassing the
+    # normal runqueue dependency resolver.
+    tinfoil.run_prepared_task('a1', 'install')
+"""
+        with self._build_dir() as builddir:
+            self._run_script(builddir, script)
+
+            tasks = self._read_tasklog(builddir)
+            self.assertEqual(tasks, ['a1:install'],
+                             'run_prepared_task should rerun only install, got: %s' % tasks)
+
+    def test_run_prepared_task_unbuilt(self):
+        """run_prepared_task() does not pull any dependency task into the runqueue.
+
+        buildFileInternal()'s taskonly=True clears task_deps['parents'] for
+        every task of the recipe, so do_install's intra-recipe predecessors
+        (fetch, unpack, patch, ...) are never added to the runqueue at all.
+        The recipe was never built here (no stamps exist for any of them), so
+        this is the case that would actually catch a regression: without
+        taskonly, those missing-stamp tasks would have to run to satisfy the
+        runqueue, and the tasklog assertion below would show more than just
+        'a1:install'.
+
+        Whether do_install itself then succeeds or fails is a separate matter
+        that does depend on the recipe: this fixture's do_install has no real
+        prerequisites, so it succeeds here; test_run_prepared_task_unbuilt_dependent_fails()
+        uses a recipe whose do_install does have one, and fails instead.
+        """
+        script = """
+import bb.tinfoil
+
+with bb.tinfoil.Tinfoil() as tinfoil:
+    tinfoil.prepare(quiet=2)
+    assert tinfoil.run_prepared_task('a1', 'install') is True
+"""
+        with self._build_dir() as builddir:
+            self._run_script(builddir, script)
+
+            tasks = self._read_tasklog(builddir)
+            self.assertEqual(tasks, ['a1:install'],
+                             'run_prepared_task should run no dependency task, got: %s' % tasks)
+
+    def test_run_prepared_task_unbuilt_dependent_fails(self):
+        """A task with a genuine dependency on a predecessor's output fails
+        when that predecessor never ran.
+
+        Unlike test_run_prepared_task_unbuilt()'s fixture recipe, whose tasks
+        are dummy stamptask() calls with no real prerequisites,
+        dependent_recipe's do_install actually needs do_compile's output.
+        run_prepared_task() only skips bitbake's own dependency resolution;
+        it does not make the prerequisites appear, exactly as documented:
+        "everything the task consumes must already be in place".
+        """
+        script = """
+import bb.tinfoil
+
+with bb.tinfoil.Tinfoil() as tinfoil:
+    tinfoil.prepare(quiet=2)
+    assert tinfoil.run_prepared_task('dependent', 'install') is False
+"""
+        with tempfile.TemporaryDirectory(prefix='tinfoilrecipes') as recipes, \
+             self._build_dir() as builddir:
+            with open(os.path.join(recipes, 'dependent.bb'), 'w') as f:
+                f.write(self.dependent_recipe)
+            self._run_script(builddir, script,
+                             {'EXTRA_BBFILES': '%s/*.bb' % recipes})
+
+    def test_run_prepared_task_unbuilt_dependent_fails_shell(self):
+        """Same as test_run_prepared_task_unbuilt_dependent_fails(), but with
+        shell do_compile/do_install tasks instead of python ones, matching how
+        a real recipe's do_install is usually written.
+        """
+        script = """
+import bb.tinfoil
+
+with bb.tinfoil.Tinfoil() as tinfoil:
+    tinfoil.prepare(quiet=2)
+    assert tinfoil.run_prepared_task('shelldependent', 'install') is False
+"""
+        with tempfile.TemporaryDirectory(prefix='tinfoilrecipes') as recipes, \
+             self._build_dir() as builddir:
+            with open(os.path.join(recipes, 'shelldependent.bb'), 'w') as f:
+                f.write(self.shell_dependent_recipe)
+            self._run_script(builddir, script,
+                             {'EXTRA_BBFILES': '%s/*.bb' % recipes})
+
+    def test_run_prepared_task_failure(self):
+        """A failing task makes run_prepared_task() return False, not raise."""
+        script = """
+import bb.tinfoil
+
+with bb.tinfoil.Tinfoil() as tinfoil:
+    tinfoil.prepare(quiet=2)
+    assert tinfoil.run_prepared_task('failer', 'install') is False
+"""
+        with tempfile.TemporaryDirectory(prefix='tinfoilrecipes') as recipes, \
+             self._build_dir() as builddir:
+            with open(os.path.join(recipes, 'failer.bb'), 'w') as f:
+                f.write(self.failing_recipe)
+            self._run_script(builddir, script,
+                             {'EXTRA_BBFILES': '%s/*.bb' % recipes})
+
+    def test_run_prepared_task_recipecache_self_heals(self):
+        """A normal full parse after run_prepared_task() sees every recipe.
+
+        run_prepared_task() -> buildFileInternal() -> parseConfiguration()
+        wipes and repopulates self.recipecaches[mc]/self.collections[mc]
+        for just the one recipe it builds - that has always been true of
+        "bitbake -b" too. Prove this is only a transient footprint: a
+        subsequent normal, full parse (as any non -b build would trigger)
+        must see the complete recipe set again, not just the one recipe
+        run_prepared_task() touched.
+        """
+        script = """
+import bb.tinfoil
+
+with bb.tinfoil.Tinfoil() as tinfoil:
+    tinfoil.prepare(quiet=2)
+    tinfoil.parse_recipes()
+    recipes_before = tinfoil.run_command('getRecipes')
+    assert len(recipes_before) > 1, 'test fixture should have more than one recipe'
+
+    tinfoil.run_prepared_task('a1', 'install')
+
+    tinfoil.parse_recipes()
+    recipes_after = tinfoil.run_command('getRecipes')
+    assert len(recipes_after) == len(recipes_before), (
+        'recipe cache not fully restored after run_prepared_task(): '
+        'before=%d after=%d' % (len(recipes_before), len(recipes_after)))
+"""
+        with self._build_dir() as builddir:
+            self._run_script(builddir, script)
+
+
 class CookerTest(unittest.TestCase):
     def setUp(self):
         # At least one variable needs to be set
-- 
2.55.0
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.