Skip to content

Instantly share code, notes, and snippets.

@Klaster1
Created March 9, 2026 11:48
Show Gist options
  • Select an option

  • Save Klaster1/937774c26370c1be6eeb645dc26b47c6 to your computer and use it in GitHub Desktop.

Select an option

Save Klaster1/937774c26370c1be6eeb645dc26b47c6 to your computer and use it in GitHub Desktop.
Angular Vitest convertion helpers
/**
* Wraps a set of Jasmine expects in a retry loop until they pass or a timeout is reached.
*
* Usage notes:
* * Do not wrap non-idempotent side effects with this.
* * Always runs the body at least twice: once to determine stability, one to delegate back to Jasmine.
* * To not waste time, try to wrap as few expression as necessary.
*
* Example:
* ```
* await expectEventually(async () => {
* expect(await someAsyncOperation()).toEqual(expectedValue);
* });
* ```
*/
export const expectEventually = async <T = undefined>(
conditionFn: () => Promise<T> | T,
params: { timeout?: number; interval?: number } = {},
): Promise<T> => {
{
// TODO:
// a. Temporarily replace jasmine.DEFAULT_TIMEOUT_INTERVAL with out timeout
// b. Count time instead of attempts and finish before jasmine.DEFAULT_TIMEOUT_INTERVAL
const { timeout = 500, interval = 10 } = params;
const maxAttempts = Math.ceil(timeout / interval);
let attempts = 0;
type Expector = { processResult: (result: { pass: boolean }, errorForStack: {}) => void };
type Result = { pass: boolean };
type ErrorForStack = {};
const original = (jasmine as any).Expector.prototype.processResult;
const results = new Map<Expector, [Result, ErrorForStack]>();
while (attempts < maxAttempts) {
attempts += 1;
results.clear();
(jasmine as any).Expector.prototype.processResult = function (
this: Expector,
result: Result,
errorForStack: ErrorForStack,
) {
results.set(this, [result, errorForStack]);
};
try {
await conditionFn();
} catch (error) {
await new Promise((resolve) => setTimeout(resolve, interval));
continue;
}
const allSuccess = Array.from(results.entries()).every(([_, [result]]) => result.pass);
// console.log(`Success: ${allSuccess}, attempts: ${attempts}, max attempts: ${maxAttempts}`);
if (allSuccess || attempts >= maxAttempts) {
(jasmine as any).Expector.prototype.processResult = original;
return await conditionFn();
} else {
await new Promise((resolve) => setTimeout(resolve, interval));
}
}
// This should never be reached, but TypeScript needs it for type safety
(jasmine as any).Expector.prototype.processResult = original;
throw new Error(`expectEventually: All attempts exhausted within ${timeout}ms`);
}
};

Zoneless Test Migration Automation Prompt

Overview

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.

Instructions

Step 1: Baseline Verification

Note: For instructions on running frontend unit tests, see the run-frontend-unit-tests skill.

  1. Run the test suite in watch mode to verify all tests currently pass with zone-based change detection
  2. Confirm all tests are green before proceeding
  3. 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)
  4. Keep the test runner in watch mode for the next steps

Step 2: Enable Zoneless Change Detection

  1. Look at the current unit test file (*.spec.ts) that has the zoneless provider commented out: // provideZonelessChangeDetection()
  2. Uncomment the provider: provideZonelessChangeDetection()
  3. Add the missing import provideZonelessChangeDetection from @angular/core (place AFTER the file header, with other Angular imports)
  4. 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();

Step 3: Identify Failing Tests

  1. Observe the test runner (which should still be in watch mode) and wait for it to re-run tests
  2. Create a TODO list of all failing tests with their specific error messages
  3. Document the failure patterns to guide the fix strategy
  4. 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

Step 4: Apply Fixes Systematically

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.

Common Fix Patterns

Fix 1: Remove TestHost Components (if simple wrapper)

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) to TestBed.createComponent(ActualComponent)
  • Change TestbedHarnessEnvironment.loader(fixture).getHarness() to TestbedHarnessEnvironment.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);

Fix 2: Convert TestHost Properties to Signals (if complex TestHost)

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 of testHost.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));
}

Fix 3: Remove Manual Change Detection

Action: Remove all instances of:

  • fixture.detectChanges()
  • Any manual change detection calls

Fix 4: Remove Timing Waits

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))

Fix 5: Wrap Assertions in expectEventually (Only When Needed)

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 events
  • open() / close() - Opens/closes menus, dialogs, dropdowns
  • select() / selectOption() / selectVisibleOption() - Changes selection state
  • setValue() / setInput() - Modifies input values
  • toggleSelection() - Changes row selection state
  • submit() - Submits forms
  • dispatch() - Dispatches store actions
  • Any method that modifies component state, DOM, or triggers events

Functions that ARE SAFE inside expectEventually:

  • getText() / getValueText() - Reads displayed text
  • isDisabled() / isEnabled() - Checks element state
  • isOpen() / isVisible() - Checks visibility state
  • getAttribute() - 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:

  • expectEventually retries 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 expectEventually to 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.

Fix 6: Update Provider Imports

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...
  ],
});

Fix 7: Remove Unnecessary compileComponents

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.

Step 5: Verify All Tests Pass

  1. Once you've worked through all items in your TODO list, observe the test runner (which should still be running in watch mode)
  2. Confirm that all tests are now passing consistently
  3. If any tests still fail, add them back to the TODO list and continue applying fixes
  4. Do not proceed to Step 6 until 100% of tests pass
  5. NEVER use sleep, timeout, or wait commands - just observe the test runner output directly
  6. CRITICAL: NEVER use xit() or xdescribe() 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.

Step 6: Remove TestHost Component (if applicable)

⚠️ Warning! Only proceed with this step after all tests pass in Step 5.

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:

  1. Update imports: Remove Component and signal from @angular/core imports if no longer needed
  2. Update fixture type: Change from ComponentFixture<TestHostComponent> to ComponentFixture<ActualComponent>
  3. Update beforeEach:
    • Change TestBed.createComponent(TestHostComponent) to TestBed.createComponent(ActualComponent)
    • Remove component variable
    • Replace loader with direct harness calls
    • Note: Components are standalone by default, so you don't need to add them to TestBed imports
  4. Apply TestHost styles directly: Move any TestHost CSS to fixture.nativeElement.style properties
  5. Update all tests:
    • Replace component.property.set(value) with fixture.componentRef.setInput('property', value)
    • Replace loader.getHarness(Harness) with TestbedHarnessEnvironment.harnessForFixture(fixture, Harness)
  6. 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...
});
  1. Verify tests still pass: The test runner should automatically re-run tests. Confirm all tests still pass after removing TestHost.

Step 7: Stability Testing

⚠️ CRITICAL: Only proceed with this step if 100% of tests pass in Step 5. If any tests are skipped (xit/xdescribe) or failing, DO NOT proceed to stability testing. Go back and fix all issues first.

⚠️ CRITICAL: NEVER run shell commands or loops to execute tests multiple times. You are ONLY allowed to wrap the test suite in a for loop in the test file itself.

  1. Keep the test runner running in watch mode - it should already be running from previous steps
  2. Wrap the uppermost describe block 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.

  1. Observe the test runner - it will automatically detect the file change and re-run all tests with 100 iterations
  2. NEVER run shell commands to execute tests multiple times - only modify the test file code itself with the for loop wrapper
  3. Verify all iterations pass
  4. If any iterations fail with actual test failures (not disconnections), investigate the specific failure patterns and apply additional expectEventually wrapping as needed
  5. The test runner will continue to re-run tests as you make fixes - continue until all iterations pass consistently
  6. 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
  7. No need to re-run tests after removing the for loop - the stability check already validated everything

Step 8: Final Cleanup

  1. Remove the for loop wrapper added in Step 7
  2. Verify the final file looks clean with no temporary debugging code
  3. Do not re-run tests - the migration is complete, and the 100x stability check already confirmed everything works
  4. 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.
  5. The test file is now ready to commit with zoneless change detection enabled

Important Notes

  • 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 expectEventually blocks for different assertion groups
  • Keep assertions grouped logically within each expectEventually block

Common Patterns from Recent Commits

Pattern 1: Simple TestHost Removal

See commit c1ef18ec - BinaryDetailsComponent conversion where TestHost was completely removed in favor of direct component testing.

Pattern 2: TestHost with Signals

See commit 5357135 - BinaryTypesComponent conversion where TestHost properties were converted to signals.

Pattern 3: Assertion Wrapping

Most commits show extensive use of expectEventually to wrap assertions that verify UI state after actions like filtering, sorting, or navigation.

Expected Outcome

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

What never to do

  1. Never add @Input or @Output decorators to TestHost.
  2. Never remove existing test steps or change test scenarios.
  3. NEVER disable tests (using xit or xdescribe) 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().
  4. 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.
  5. 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment