# HG changeset patch # User cin # Date 2026-04-20 19:19:58 # Node ID a06b08ec0a7f514c58d03cf4c544988aa5447f0a # Parent 3469331e3c4b01d904594191417d9559cc756808 variants: stabilize artifact slot materialization diff --git a/variants/src/main/java/org/implab/gradle/variants/VariantArtifactsPlugin.java b/variants/src/main/java/org/implab/gradle/variants/VariantArtifactsPlugin.java --- a/variants/src/main/java/org/implab/gradle/variants/VariantArtifactsPlugin.java +++ b/variants/src/main/java/org/implab/gradle/variants/VariantArtifactsPlugin.java @@ -49,7 +49,7 @@ public abstract class VariantArtifactsPl // wire artifact assemblies to configuration variants var assembliesBridge = new ArtifactAssemblyBinder(assemblies); var primarySlotConvention = new SingleSlotConvention(providers); - var materializationHandler = new MaterializationPolicyHandler(); + var materializationHandler = new MaterializationPolicyHandler(objects); // bind slot assemblies to outgoing variants outgoing.configureEach(assembliesBridge::execute); diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/ArtifactAssemblies.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/ArtifactAssemblies.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/ArtifactAssemblies.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/ArtifactAssemblies.java @@ -51,6 +51,11 @@ public interface ArtifactAssemblies { */ void configureEach(Action action); - /** Ищет зарегистрированный слот */ + /** + * Finds a registered assembly for the given slot identity. + * + * @param slot slot identity inside a variant outgoing contract + * @return registered assembly, if the slot body has already been materialized + */ Optional find(ArtifactSlot slot); } diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingConfigurationSpec.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingConfigurationSpec.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingConfigurationSpec.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingConfigurationSpec.java @@ -11,7 +11,7 @@ import groovy.lang.Closure; * Materialized root outgoing configuration of a variant. * *

This is a variant-level publication hook. Slot-specific publication state is exposed separately via - * {@link OutgoingArtifactSlotSpec}. + * {@link OutgoingConfigurationSlotSpec}. */ public interface OutgoingConfigurationSpec { /** diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingVariant.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingVariant.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingVariant.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingVariant.java @@ -8,43 +8,56 @@ import org.gradle.api.provider.Property; import org.implab.gradle.variants.core.Variant; /** - * Описывает исходящую конфигурацию варианта + * Plugin model object for one variant-level outgoing contract. * - * Задает связь между моделью вариантов и моделью конфигураций gradle через - * свойство {@link #getConfiguration()}. Также задает отдельную ось слотов - * публикации, но не задает правил связывания этих слотов с самой конфигурацией - * и их содержимым. Самый простой вариант это {@link ArtifactAssemblies}. + *

An outgoing variant connects a core {@link Variant} identity with the lazy + * Gradle consumable configuration registered for that variant. It also exposes a + * live container of slot identities. Slot identities are only declarations and do + * not imply that a Gradle outgoing artifact variant or an {@link ArtifactAssembly} + * has been materialized. */ public interface OutgoingVariant { /** - * Исходный вариант для которого строится Outgoing конфигурация + * Returns the variant that owns this outgoing contract. + * + * @return variant identity */ Variant getVariant(); /** - * Провайдер зарегистрированной конфигурации + * Returns the provider of the registered consumable outgoing configuration. + * + * @return outgoing configuration provider */ NamedDomainObjectProvider getConfiguration(); + /** + * Configures the registered outgoing configuration. + * + * @param action configuration action + */ default void configureOutgoing(Action action) { getConfiguration().configure(action); } /** - * Слоты конфигурации, данная коллекция живая, используется для - * получения информации об объявленных слотах, но эти слоты не - * обязаны быть сконфигурированы, т.е. это только Identity. + * Returns the live slot identity container. + * + *

This collection is intended for discovery and selection. Slot presence does + * not guarantee that the slot has a configured assembly body or a materialized + * Gradle outgoing artifact variant. * * @see {@link ArtifactSlot} */ NamedDomainObjectContainer getSlots(); /** - * Основной набор артефактов (primary variant) для исходящей конфигурации + * Returns the primary slot property. * - *

- * Если в свойстве {@link #getSlots()} есть только один слой, то по конвенции он - * считается также основным. + *

If exactly one slot is declared, the single-slot convention uses that slot as + * the primary one. Reading this property finalizes the selected primary slot. + * + * @return primary slot property */ Property getPrimarySlot(); } diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingVariantsContext.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingVariantsContext.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingVariantsContext.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/OutgoingVariantsContext.java @@ -7,29 +7,80 @@ import org.gradle.api.InvalidUserDataExc import org.implab.gradle.variants.core.Variant; /** - * Контекст работы с вариантами публикации, становится доступным после - * финализации модели вариантов. Фактически является живой моделью + * Live context for declaring and observing variant outgoing publications. + * + *

The context becomes available after the core variant model has been finalized. + * It owns variant-level outgoing declarations, assembly lookup, and hooks for + * materialized Gradle-facing publication state. */ public interface OutgoingVariantsContext { + /** + * Returns the assembly lookup service. + * + *

Assemblies are stateful slot bodies resolved from cheap {@link ArtifactSlot} + * identities. + * + * @return assembly lookup service + */ ArtifactAssemblies getAssemblies(); + /** + * Configures artifact slots of the given variant. + * + * @param variant variant identity + * @param action variant artifact declaration + */ void configureVariant(Variant variant, Action action); /** - * Replayable hook для всех объявленных конфигураций + * Registers a replayable action for declared outgoing variants. + * + *

The action receives the plugin model object, not necessarily a materialized + * Gradle publication. Use {@link #whenOutgoingConfiguration(Action)} and + * {@link #whenOutgoingSlot(Action)} for materialized Gradle-facing state. + * + * @param action outgoing variant action */ void configureEach(Action action); + /** + * Finds the outgoing model for the given variant. + * + * @param variant variant identity + * @return outgoing model, if declared + */ Optional findOutgoing(Variant variant); + /** + * Requires the outgoing model for the given variant. + * + * @param variant variant identity + * @return outgoing model + * @throws InvalidUserDataException if the outgoing variant is not declared + */ default OutgoingVariant requireOutgoing(Variant variant) { return findOutgoing(variant) .orElseThrow(() -> new InvalidUserDataException("Outgoing variant '" + variant + "' isn't registered")); } + /** + * Registers a replayable action for materialized variant-level outgoing + * configurations. + * + * @param action materialized outgoing configuration action + */ void whenOutgoingConfiguration(Action action); + /** + * Registers a replayable action for materialized slot publications. + * + *

Slot publication hooks follow the Gradle outgoing publication model. A + * declared slot identity by itself does not guarantee that a slot publication has + * been materialized. + * + * @param action materialized slot publication action + */ void whenOutgoingSlot(Action action); diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/VariantArtifactsSpec.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/VariantArtifactsSpec.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/VariantArtifactsSpec.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/VariantArtifactsSpec.java @@ -17,7 +17,6 @@ public interface VariantArtifactsSpec { * * @param name slot name * @param action slot declaration - * @return slot identity */ void slot(String name, Action action); @@ -30,7 +29,6 @@ public interface VariantArtifactsSpec { * * @param name slot name * @param action slot declaration - * @return slot identity */ void primarySlot(String name, Action action); diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyBinder.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyBinder.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyBinder.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyBinder.java @@ -7,8 +7,7 @@ import org.implab.gradle.variants.artifa import org.implab.gradle.variants.artifacts.OutgoingVariant; /** - * Связывает описание исходящих конфигураций gradle и сборку содержимого слотов - * из {@link ArtifactAssemblies} + * Binds materialized slot assemblies to Gradle outgoing publications. */ @NonNullByDefault public class ArtifactAssemblyBinder implements Action { @@ -25,28 +24,29 @@ public class ArtifactAssemblyBinder impl var primarySlotProvider = outgoingVariant.getPrimarySlot(); var variant = outgoingVariant.getVariant(); - // связываем конфигурацию + // Bind publication state when the owning configuration is materialized. outgoingVariant.configureOutgoing(configuration -> { var primarySlot = primarySlotProvider.get(); var outgoing = configuration.getOutgoing(); - // связываем основной вариант конфигурации + // Bind the primary artifact set to the root outgoing configuration. resolver.when( new ArtifactSlot(variant, primarySlot), assembly -> outgoing.artifact(assembly.getArtifact())); - // для всех объявленных слотов + // Bind non-primary slots to Gradle secondary artifact variants. slots.all(slot -> { - // кроме основного if (slot.equals(primarySlot)) return; - // связываем артефакты resolver.when( new ArtifactSlot(variant, slot), + // Gradle artifact variants must be created while the owning + // configuration is being materialized. Lazy registration may + // otherwise be realized only after dependency resolution starts. assembly -> outgoing.getVariants() - .register(slot.getName()) - .configure(cv -> cv.artifact(assembly.getArtifact()))); + .create(slot.getName()) + .artifact(assembly.getArtifact())); }); }); } diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyHandler.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyHandler.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyHandler.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/ArtifactAssemblyHandler.java @@ -17,6 +17,7 @@ import org.gradle.api.tasks.Sync; import org.gradle.api.tasks.TaskContainer; import org.gradle.language.base.plugins.LifecycleBasePlugin; import org.implab.gradle.common.core.lang.FilePaths; +import org.implab.gradle.variants.artifacts.ArtifactAssembly; import org.implab.gradle.variants.artifacts.ArtifactAssemblySpec; import org.implab.gradle.variants.artifacts.ArtifactSlot; import org.implab.gradle.variants.sources.CompileUnit; @@ -25,26 +26,17 @@ import org.implab.gradle.variants.source import org.implab.gradle.variants.sources.SourceSetMaterializer; /** - * Адаптер между фрагментами артефактов, представленными в виде - * {@link SlotContribution}, - * и ArtifactAssemblyRegistry, который оперирует уже собранными - * {@link ArtifactAssembly}. + * Adapts slot contribution declarations to materialized {@link ArtifactAssembly} + * handles. * - * Данный класс отвечает за сборку отдельных фрагментов артефактов в единый - * каталог, который затем регистрируется в ArtifactAssemblyRegistry в виде - * {@link ArtifactAssembly}. Сборка конечного артефакта происходит при помощи - * задачи, которая копирует все входные файлы в выходной каталог. Задача - * создается для каждого {@link ArtifactSlot} и использует - * {@link SlotAssembly#inputs()} как источник входных данных. + *

The handler creates one {@link Sync} task per {@link ArtifactSlot}. The task + * copies all collected slot inputs into a single output directory. That output + * directory is then registered in {@link ArtifactAssemblyRegistry} as the + * published artifact for the slot. * - * Для сборки используется паттерн Visitor: каждый фрагмент артефакта - * представлен в виде реализации интерфейса {@link SlotContribution}, который - * имеет метод accept, принимающий Visitor. - * Visitor реализован во внутреннем классе {@link ContributionVisitor}, который - * знает, как обрабатывать каждый тип фрагмента и добавлять его в сборку. - * Для каждого {@link SlotContribution} создается ключ {@link SlotInputKey}, - * который используется для дедупликации: если фрагмент с таким же ключом уже - * был добавлен, то он игнорируется. + *

Input collection uses {@link SlotContributionVisitor}. Each contribution is + * converted to a {@link SlotInputKey}; duplicate keys are ignored so that repeated + * topology-based selections do not add the same input twice. */ @NonNullByDefault public class ArtifactAssemblyHandler { @@ -97,9 +89,7 @@ public class ArtifactAssemblyHandler { } /** - * Создает сборку для указанного слота артефакта, сборка регистрируется в - * ArtifactAssemblyRegistry, если для слота сборка уже была зарегистрирована - * кем-то еще, то возникает ошибка. + * Creates the assembly task for the given slot and registers its output artifact. */ private SlotAssembly createSlotAssembly(ArtifactSlot artifactSlot) { var assembly = new SlotAssembly(); @@ -135,13 +125,7 @@ public class ArtifactAssemblyHandler { } /** - * Собирает отдельные фрагменты артефактов в единый источник - * {@link ConfigurableFileCollection}. - * Для фрагментов {@link SlotContribution} используется механизм дедупликации: - * для каждого - * водящего фрагмента создается ключ {@link SlotInputKey} и добавляются - * фрагменты только - * с уникальным ключом, повторы игнорируются. + * Collects slot contributions into one {@link ConfigurableFileCollection}. */ private class ContributionVisitor implements SlotContributionVisitor { // artifact slot for this assembly @@ -200,7 +184,7 @@ public class ArtifactAssemblyHandler { } } - /** Состояние для отдельного слота */ + /** Mutable input state for one slot assembly. */ class SlotAssembly { private final ConfigurableFileCollection inputs = objects.fileCollection(); private final Set seen = new HashSet<>(); diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/DefaultArtifactAssemblySpec.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/DefaultArtifactAssemblySpec.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/DefaultArtifactAssemblySpec.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/DefaultArtifactAssemblySpec.java @@ -15,10 +15,12 @@ import org.implab.gradle.variants.core.R import org.implab.gradle.variants.artifacts.OutputSelectionSpec; /** - * Реализация DSL модели, строит набор {@link SlotContribution}. При построении набора - * правила и корректность не проверяются. По окончании использования клиент - * вызывает метод {@link #process(Consumer)} для обработки результатов. + * Default DSL facade for collecting {@link SlotContribution} declarations. * + *

The spec does not validate topology references immediately. It translates DSL + * calls to contribution objects and passes them to the supplied consumer; semantic + * validation happens later when the assembly handler resolves contributions + * against the finalized source model. */ @NonNullByDefault final class DefaultArtifactAssemblySpec implements ArtifactAssemblySpec { diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/MaterializationPolicyHandler.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/MaterializationPolicyHandler.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/MaterializationPolicyHandler.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/MaterializationPolicyHandler.java @@ -4,11 +4,13 @@ import org.eclipse.jdt.annotation.NonNul import org.gradle.api.Action; import org.gradle.api.artifacts.Configuration; import org.gradle.api.attributes.AttributeContainer; -import org.implab.gradle.internal.ReplayableQueue; +import org.gradle.api.model.ObjectFactory; +import org.implab.gradle.common.core.lang.ReplayableQueue; import org.implab.gradle.variants.artifacts.ArtifactSlot; import org.implab.gradle.variants.artifacts.OutgoingConfigurationSlotSpec; import org.implab.gradle.variants.artifacts.OutgoingConfigurationSpec; import org.implab.gradle.variants.artifacts.OutgoingVariant; +import org.implab.gradle.variants.artifacts.Slot; import org.implab.gradle.variants.core.Variant; /** @@ -32,17 +34,18 @@ public class MaterializationPolicyHandle private final ReplayableQueue variantMaterialization = new ReplayableQueue<>(); private final ReplayableQueue slotMaterialization = new ReplayableQueue<>(); + private final ObjectFactory objects; - public MaterializationPolicyHandler() { + public MaterializationPolicyHandler(ObjectFactory objects) { + this.objects = objects; } @Override public void execute(OutgoingVariant outgoingVariant) { - var slots = outgoingVariant.getSlots(); var primarySlotProvider = outgoingVariant.getPrimarySlot(); var variant = outgoingVariant.getVariant(); - // связываем конфигурацию + // Materialization hooks are attached to the owning outgoing configuration. outgoingVariant.configureOutgoing(configuration -> { var primarySlot = primarySlotProvider.get(); var outgoing = configuration.getOutgoing(); @@ -51,11 +54,13 @@ public class MaterializationPolicyHandle slotMaterialized(new ArtifactSlot(variant, primarySlot), true, outgoing.getAttributes()); - slots.forEach(slot -> { + // Slot publication hooks follow materialized Gradle artifact variants, + // not the live slot identity view. + outgoing.getVariants().configureEach(variantConfiguration -> { + var slot = objects.named(Slot.class, variantConfiguration.getName()); if (slot.equals(primarySlot)) return; - var variantConfiguration = outgoing.getVariants().getByName(slot.getName()); slotMaterialized(new ArtifactSlot(variant, slot), false, variantConfiguration.getAttributes()); }); }); diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/SingleSlotConvention.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/SingleSlotConvention.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/SingleSlotConvention.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/internal/SingleSlotConvention.java @@ -18,7 +18,7 @@ public class SingleSlotConvention implem var slots = outgoingVariant.getSlots(); outgoingVariant.getPrimarySlot().convention( - // если есть ровно один слот, то он считается primary + // If exactly one slot is declared, it becomes the primary slot. providers.provider(() -> { if (slots.size() == 0) throw new InvalidUserDataException("No slots declared for " + outgoingVariant.getVariant()); diff --git a/variants/src/main/java/org/implab/gradle/variants/artifacts/package-info.java b/variants/src/main/java/org/implab/gradle/variants/artifacts/package-info.java --- a/variants/src/main/java/org/implab/gradle/variants/artifacts/package-info.java +++ b/variants/src/main/java/org/implab/gradle/variants/artifacts/package-info.java @@ -33,9 +33,8 @@ * } * } * - *

After finalization, slot identities can be observed through - * {@link org.implab.gradle.variants.artifacts.OutgoingVariantsContext#getSlots()}, while slot bodies are - * obtained on demand through + *

After finalization, {@link org.implab.gradle.variants.artifacts.OutgoingVariant} exposes declared slot + * identities through {@code getSlots()}, while slot bodies are obtained on demand through * {@link org.implab.gradle.variants.artifacts.OutgoingVariantsContext#getAssemblies()}. */ package org.implab.gradle.variants.artifacts; diff --git a/variants/src/test/java/org/implab/gradle/variants/VariantArtifactsPluginFunctionalTest.java b/variants/src/test/java/org/implab/gradle/variants/VariantArtifactsPluginFunctionalTest.java --- a/variants/src/test/java/org/implab/gradle/variants/VariantArtifactsPluginFunctionalTest.java +++ b/variants/src/test/java/org/implab/gradle/variants/VariantArtifactsPluginFunctionalTest.java @@ -103,6 +103,74 @@ class VariantArtifactsPluginFunctionalTe } @Test + void gradleReferenceRegisteredSecondaryArtifactVariantIsNotRealizedBeforeResolution() throws Exception { + // Gradle issue: https://github.com/gradle/gradle/issues/27441 + // Registered outgoing artifact variants are not realized before dependency resolution. + writeFile("settings.gradle", """ + rootProject.name = 'gradle-reference-registered-secondary-variant' + include 'producer', 'consumer' + """); + writeFile("producer/inputs/typesPackage", "types\n"); + writeFile("producer/inputs/js", "js\n"); + writeFile("build.gradle", """ + import org.gradle.api.attributes.Attribute + + def variantAttr = Attribute.of('test.variant', String) + def slotAttr = Attribute.of('test.slot', String) + + project(':producer') { + def browserElements = configurations.consumable('browserElements') + + browserElements.configure { configuration -> + configuration.attributes.attribute(variantAttr, 'browser') + configuration.outgoing.attributes.attribute(slotAttr, 'typesPackage') + configuration.outgoing.artifact(layout.projectDirectory.file('inputs/typesPackage')) + + configuration.outgoing.variants.register('js') { secondary -> + secondary.attributes.attribute(slotAttr, 'js') + secondary.artifact(layout.projectDirectory.file('inputs/js')) + } + } + } + + project(':consumer') { + configurations { + compileView { + canBeResolved = true + canBeConsumed = false + canBeDeclared = true + attributes { + attribute(variantAttr, 'browser') + attribute(slotAttr, 'typesPackage') + } + } + } + + dependencies { + compileView project(':producer') + } + + tasks.register('probe') { + doLast { + def compileFiles = configurations.compileView.files.collect { it.name }.sort().join(',') + def jsFiles = configurations.compileView.incoming.artifactView { + attributes { + attribute(slotAttr, 'js') + } + }.files.files.collect { it.name }.sort().join(',') + + println('compileFiles=' + compileFiles) + println('jsFiles=' + jsFiles) + } + } + } + """); + + assertBuildFails("Cannot create variant 'js' after dependency configuration ':producer:browserElements' has been resolved", + ":consumer:probe"); + } + + @Test void materializesPrimaryAndSecondarySlotsAndInvokesOutgoingHooks() throws Exception { writeSettings("variant-artifacts-slots"); writeFile("inputs/base.js", "console.log('base')\n"); @@ -207,6 +275,66 @@ class VariantArtifactsPluginFunctionalTe } @Test + void outgoingSlotHookFollowsMaterializedGradleArtifactVariants() throws Exception { + writeSettings("variant-artifacts-materialized-gradle-variant"); + writeFile("inputs/typesPackage", "types\n"); + writeFile("inputs/js", "js\n"); + writeBuildFile(""" + import org.gradle.api.attributes.Attribute + + apply plugin: org.implab.gradle.variants.VariantArtifactsPlugin + + def slotAttr = Attribute.of('test.slot', String) + + variants.layers.create('main') + variants.roles.create('main') + variants.variant('browser') { + role('main') { + layers('main') + } + } + + variantArtifacts { + variant('browser') { + primarySlot('typesPackage') { + from(layout.projectDirectory.file('inputs/typesPackage')) + } + } + + whenOutgoingConfiguration { publication -> + publication.configuration { + outgoing.variants.create('js') { secondary -> + secondary.artifact(layout.projectDirectory.file('inputs/js')) + } + } + } + + whenOutgoingSlot { publication -> + publication.artifactAttributes { + attribute(slotAttr, publication.artifactSlot.slot.name) + } + } + } + + tasks.register('probe') { + doLast { + def elements = configurations.getByName('browserElements') + def jsVariant = elements.outgoing.variants.getByName('js') + + println('primarySlotAttr=' + elements.outgoing.attributes.getAttribute(slotAttr)) + println('jsSlotAttr=' + jsVariant.attributes.getAttribute(slotAttr)) + } + } + """); + + BuildResult result = runner("probe").build(); + + assertTrue(result.getOutput().contains("primarySlotAttr=typesPackage")); + assertTrue(result.getOutput().contains("jsSlotAttr=js")); + assertTrue(result.task(":probe").getOutcome() == TaskOutcome.SUCCESS); + } + + @Test void allowsSingleSlotVariantWithoutExplicitPrimarySlot() throws Exception { writeSettings("variant-artifacts-single-slot"); writeBuildFile("""