This prompt automates the conversion of Angular unit tests (*.spec.ts) from zone-based to zoneless change detection by uncommenting provideZonelessChangeDetection() and fixing the resulting test issues.
Note: For instructions on running frontend unit tests, see the run-frontend-unit-tests skill.
- Run the test suite in watch mode to verify all tests currently pass with zone-based change detection
- Confirm all tests are green before proceeding
- Record the baseline test execution time from the terminal output (e.g., "Executed 8 of 8 SUCCESS (0.448 secs / 0.412 secs)" - note the execution time)
- Keep the test runner in watch mode for the next steps
- Look at the current unit test file (*.spec.ts) that has the zoneless provider commented out:
// provideZonelessChangeDetection() - Uncomment the provider:
provideZonelessChangeDetection() - Add the missing import
provideZonelessChangeDetectionfrom@angular/core(place AFTER the file header, with other Angular imports) - Make sure no comments like these remain around zoneless provider:
// TODO Zoneless: Breaks because of:
// - Grid API access in setTimeout in node selection dialog
// - Grid selection UI not rendering
provideZonelessChangeDetection();- Observe the test runner (which should still be in watch mode) and wait for it to re-run tests
- Create a TODO list of all failing tests with their specific error messages
- Document the failure patterns to guide the fix strategy
- Do NOT attempt to fix anything yet - just catalog all failures
Example TODO list format:
- [ ] Test: "shows the name" - Error: Expected null to be 'Cluster 1'
- [ ] Test: "filters by type" - Error: Expected 5 to equal 3
- [ ] Test: "sorts descending" - Error: Timeout waiting for element
Work through each failing test from your TODO list, applying the appropriate fixes from the patterns below. Check off each test as you fix it.
When: TestHost component only creates a single component and passes simple bindings Action:
- ALWAYS remove the TestHost component if it's a simple wrapper - this is the preferred approach
- Use
fixture.componentRef.setInput()for setting inputs - Change
TestBed.createComponent(TestHostComponent)toTestBed.createComponent(ActualComponent) - Change
TestbedHarnessEnvironment.loader(fixture).getHarness()toTestbedHarnessEnvironment.harnessForFixture(fixture, HarnessClass) - Set any required styling directly on
fixture.nativeElement.style
Example transformation:
// BEFORE: TestHost with simple bindings
@Component({
selector: 'test-host',
template: `<my-component [data]="data" [currentItem]="currentItem"></my-component>`,
imports: [MyComponent],
})
class TestHostComponent {
data = [...];
currentItem = null;
}
// AFTER: Direct component usage
fixture = TestBed.createComponent(MyComponent);
fixture.componentRef.setInput('data', [...]);
fixture.componentRef.setInput('currentItem', null);When: TestHost uses multiple components or complex template logic that can't be easily removed Action:
- Convert all TestHost component properties to signals using
signal() - Update template to call signals:
[data]="data()"instead of[data]="data" - Update test code to use
.set()instead of direct assignment:testHost.data.set(newValue)instead oftestHost.data = newValue
Example transformation:
// BEFORE: Regular properties
class TestHostComponent {
types = [...];
currentType = null;
onTypeSelect = (id) => this.currentType = this.types.find(t => t.id === id);
}
// AFTER: Signal properties
class TestHostComponent {
types = signal([...]);
currentType = signal(null);
onTypeSelect = (id) => this.currentType.set(this.types().find(t => t.id === id));
}Action: Remove all instances of:
fixture.detectChanges()- Any manual change detection calls
Action: Remove all variations of:
await firstValueFrom(timer(0))await lastValueFrom(timer(100))await new Promise(resolve => setTimeout(resolve, 0))await new Promise((resolve) => setTimeout(resolve, 0))
When to apply: Only wrap assertions in expectEventually() if the test fails without it. Do not add it preemptively.
Action: If a test fails with timing or change detection issues, wrap the failing assertions in expectEventually() to make them auto-retry:
Import required:
import { expectEventually } from "@app/common/utils/wait";Example transformation:
// BEFORE: Direct assertions after actions
await grid.toggleColumnSort('name');
expect(await grid.getSortingState('name')).toEqual({ order: 'desc', values: [...] });
// AFTER: Wrapped in expectEventually
await grid.toggleColumnSort('name');
await expectEventually(async () => {
expect(await grid.getSortingState('name')).toEqual({ order: 'desc', values: [...] });
});Example with return value (expectEventually can return values):
// expectEventually can also return values after assertion succeeds
const password = await expectEventually(async () => {
const value = await dialog
.clusterCredentials()
.then((s) => s?.passwordField())
.then((f) => f?.getControl())
.then((c) => c?.host())
.then((h) => h?.getProperty("value"));
expect(value).toBeTruthy();
return value;
});
// Now you can use 'password' in subsequent assertions or actions
expect(password).toContain("abc");CRITICAL: How to properly use expectEventually
expectEventually should ONLY contain assertions and value retrieval. NEVER put side effects inside expectEventually. Side effects like clicks, opens, or any actions should happen OUTSIDE the retry loop.
Functions that should NEVER be called inside expectEventually (not exhaustive):
click()/dblClick()- Triggers click eventsopen()/close()- Opens/closes menus, dialogs, dropdownsselect()/selectOption()/selectVisibleOption()- Changes selection statesetValue()/setInput()- Modifies input valuestoggleSelection()- Changes row selection statesubmit()- Submits formsdispatch()- Dispatches store actions- Any method that modifies component state, DOM, or triggers events
Functions that ARE SAFE inside expectEventually:
getText()/getValueText()- Reads displayed textisDisabled()/isEnabled()- Checks element stateisOpen()/isVisible()- Checks visibility stategetAttribute()- Reads element attributes- Any getter methods that read state without modifying it
Pattern 1: Use expectEventually ONLY for assertions (most common)
// ✅ CORRECT: Just wrap assertions, no return value needed
it("should show updated value", async () => {
await page.clickButton();
// Wait for value to update, just assert
await expectEventually(async () => {
expect(await page.getValue()).toBe("expected");
});
});
// ✅ CORRECT: Multiple assertions in one expectEventually
it("should update multiple fields", async () => {
await page.submitForm();
await expectEventually(async () => {
expect(await page.getStatus()).toBe("saved");
expect(await page.getMessage()).toBe("Success");
expect(await page.getTimestamp()).toBeTruthy();
});
});Pattern 2: Use expectEventually to wait for and return a value (when you need to perform actions on it)
// ✅ CORRECT: Return value when you need it for subsequent actions
it("should do something after action", async () => {
// Step 1: Wait for element to exist and return it
const element = await expectEventually(async () => {
const el = await page.getElement();
expect(el).toBeTruthy();
return el; // Return it because we need to interact with it next
});
// Step 2: Perform side effect (click, open, etc.) OUTSIDE expectEventually
await element.performAction();
// Step 3: Just assert the result (no return needed)
await expectEventually(async () => {
expect(await page.getResult()).toBe("expected");
});
});WRONG patterns to avoid:
// ❌ WRONG: Side effect inside expectEventually
await expectEventually(async () => {
const menu = await page.getMenu();
await menu.open(); // ❌ BAD: This will open multiple times during retries!
expect(await menu.isOpen()).toBe(true);
});
// ❌ WRONG: Click inside expectEventually
await expectEventually(async () => {
const button = await page.getButton();
await button.click(); // ❌ BAD: This will click multiple times during retries!
expect(someSpy).toHaveBeenCalled();
});Why this matters:
expectEventuallyretries the function multiple times until assertions pass- Side effects inside it will execute multiple times (opening menus repeatedly, clicking buttons repeatedly, etc.)
- The correct pattern: use
expectEventuallyto wait for and return values, then perform actions outside the retry loop
Important: during this step, don't even attempt to run ng test 100 times in shell. You only can modify the code.
Action: Replace legacy providers with modern equivalents:
RouterTestingModule.withRoutes([...])→provideRouter([...])- Remove unused HTTP testing providers:
provideHttpClient(withInterceptorsFromDi()),provideHttpClientTesting()
Common Error: If you get No provider found for RouterlessTracking errors, add importProvidersFrom(Angulartics2Module) to providers array.
Example:
TestBed.configureTestingModule({
providers: [
provideZonelessChangeDetection(),
provideRouter([]),
importProvidersFrom(Angulartics2Module), // Add this if you see RouterlessTracking error
// other providers...
],
});When: Most of the time, compileComponents() is unnecessary in tests
Action: Remove await TestBed.compileComponents() calls from your test setup
Why: With standalone components and modern Angular testing, compileComponents() is rarely needed. The TestBed automatically compiles components when you call createComponent().
Example transformation:
// BEFORE: Unnecessary compileComponents
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
}).compileComponents();
fixture = TestBed.createComponent(MyComponent);
});
// AFTER: Direct component creation
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
fixture = TestBed.createComponent(MyComponent);
});Note: Only keep compileComponents() if you're using external templates/styles (templateUrl/styleUrls), but even then it's often not needed with modern build tools.
- Once you've worked through all items in your TODO list, observe the test runner (which should still be running in watch mode)
- Confirm that all tests are now passing consistently
- If any tests still fail, add them back to the TODO list and continue applying fixes
- Do not proceed to Step 6 until 100% of tests pass
- NEVER use sleep, timeout, or wait commands - just observe the test runner output directly
- CRITICAL: NEVER use
xit()orxdescribe()to skip failing tests. If tests fail and you cannot fix them after exhausting all fix patterns, report the issue and ask for guidance. Do NOT mark tests as skipped and proceed - this defeats the purpose of the migration.
Important: If the TestHost component is a simple wrapper (only wraps a single component with simple bindings), it should be removed during Step 4 when applying fixes, not deferred to Step 6. Step 6 is only for cases where you initially kept a TestHost and want to remove it after confirming tests pass.
Evaluate whether the TestHost component can be removed:
When to remove TestHost:
- TestHost only wraps a single component (✅ ALWAYS REMOVE - preferred approach)
- TestHost only passes simple bindings/inputs to the component (✅ ALWAYS REMOVE - preferred approach)
- TestHost doesn't combine multiple components in its template
- TestHost doesn't have complex template logic or structural directives
Preferred approach: Remove simple TestHost wrappers during Step 4 (Apply Fixes Systematically) rather than waiting until Step 6.
When to keep TestHost:
- TestHost combines multiple components in its template
- TestHost has complex template logic with *ngIf, *ngFor, etc.
- TestHost orchestrates interactions between multiple child components
If removing TestHost, apply these changes:
- Update imports: Remove
Componentandsignalfrom@angular/coreimports if no longer needed - Update fixture type: Change from
ComponentFixture<TestHostComponent>toComponentFixture<ActualComponent> - Update beforeEach:
- Change
TestBed.createComponent(TestHostComponent)toTestBed.createComponent(ActualComponent) - Remove
componentvariable - Replace
loaderwith direct harness calls - Note: Components are standalone by default, so you don't need to add them to TestBed imports
- Change
- Apply TestHost styles directly: Move any TestHost CSS to
fixture.nativeElement.styleproperties - Update all tests:
- Replace
component.property.set(value)withfixture.componentRef.setInput('property', value) - Replace
loader.getHarness(Harness)withTestbedHarnessEnvironment.harnessForFixture(fixture, Harness)
- Replace
- Delete TestHost component: Remove the entire TestHost class at the end of the file
Example transformation:
// BEFORE: Using TestHost
let component: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;
let loader: HarnessLoader;
beforeEach(() => {
fixture = TestBed.createComponent(TestHostComponent);
component = fixture.componentInstance;
component.clusterId.set("123");
loader = TestbedHarnessEnvironment.loader(fixture);
});
it("shows status", async () => {
component.onlyIcon.set(true);
const harness = await loader.getHarness(StatusHarness);
// assertions...
});
@Component({
selector: "test-host",
template:
'<my-component [clusterId]="clusterId()" [onlyIcon]="onlyIcon()"></my-component>',
imports: [MyComponent],
})
class TestHostComponent {
clusterId = signal("");
onlyIcon = signal(false);
}
// AFTER: Direct component testing
let fixture: ComponentFixture<MyComponent>;
beforeEach(() => {
fixture = TestBed.createComponent(MyComponent);
fixture.componentRef.setInput("clusterId", "123");
fixture.nativeElement.style.width = "100px";
fixture.nativeElement.style.height = "100px";
// Note: No need to import MyComponent in TestBed - components are standalone by default
});
it("shows status", async () => {
fixture.componentRef.setInput("onlyIcon", true);
const harness = await TestbedHarnessEnvironment.harnessForFixture(
fixture,
StatusHarness,
);
// assertions...
});- Verify tests still pass: The test runner should automatically re-run tests. Confirm all tests still pass after removing TestHost.
- Keep the test runner running in watch mode - it should already be running from previous steps
- Wrap the uppermost
describeblock in a for loop that runs 100 times to ensure stability:
for (let i = 0; i < 100; i++) {
describe("ComponentName", () => {
// All existing test content here
});
}IMPORTANT: Only change the describe block name to include the iteration counter. Do NOT rename any variables, fixtures, or other identifiers. Variable names like fixture, page, store, etc. should remain exactly as they were.
- Observe the test runner - it will automatically detect the file change and re-run all tests with 100 iterations
- NEVER run shell commands to execute tests multiple times - only modify the test file code itself with the for loop wrapper
- Verify all iterations pass
- If any iterations fail with actual test failures (not disconnections), investigate the specific failure patterns and apply additional
expectEventuallywrapping as needed - The test runner will continue to re-run tests as you make fixes - continue until all iterations pass consistently
- IMPORTANT: Once stability is confirmed (all 100 iterations pass), remove the for loop wrapper before committing - we don't want to commit the 100x loop
- No need to re-run tests after removing the for loop - the stability check already validated everything
- Remove the for loop wrapper added in Step 7
- Verify the final file looks clean with no temporary debugging code
- Do not re-run tests - the migration is complete, and the 100x stability check already confirmed everything works
- Compare test execution times: Report the performance difference between the baseline (Step 1) and final zoneless execution time. Note whether zoneless change detection improved, worsened, or had no significant impact on test performance.
- The test file is now ready to commit with zoneless change detection enabled
- Always preserve the original test logic and expectations
- Don't remove test steps or change test scenarios
- Focus only on the technical migration, not test content changes
- Some tests may require multiple
expectEventuallyblocks for different assertion groups - Keep assertions grouped logically within each
expectEventuallyblock
See commit c1ef18ec - BinaryDetailsComponent conversion where TestHost was completely removed in favor of direct component testing.
See commit 5357135 - BinaryTypesComponent conversion where TestHost properties were converted to signals.
Most commits show extensive use of expectEventually to wrap assertions that verify UI state after actions like filtering, sorting, or navigation.
After applying these fixes:
- The test should run without errors in zoneless mode
- All test scenarios should continue to pass
- The test should be more reliable and consistent
- No functional behavior should be changed, only the technical implementation
- Never add
@Inputor@Outputdecorators to TestHost. - Never remove existing test steps or change test scenarios.
- NEVER disable tests (using
xitorxdescribe) and claim you are done. If a test fails and you've exhausted all fix patterns from Step 4, report the specific failure and ask for guidance. Do NOT skip tests to make the suite appear "passing" - this completely defeats the purpose of zoneless migration. All tests must actually pass, not be hidden with xit(). - NEVER proceed to Step 7 (Stability Testing) if any tests are skipped or failing. Stability testing is only valid when 100% of tests genuinely pass. If you cannot get all tests passing, stop at Step 5 and report the blockers.
- Never revert migration changes without confirmation. If you absolutely fail to migrate after exhausting all options, ask for confirmation first. When reverting, leave your findings in a zoneless comment (near the commented
provideZonelessChangeDetection()) so there's something to resume from later.