Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fruity-camels-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'vitest-browser-angular': minor
---

feat: implement render directive function
6 changes: 4 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { beforeEach } from "vitest";
import { page } from "vitest/browser";
import { cleanup, render } from "./pure";
import { cleanup, render, renderDirective } from "./pure";
export type { Inputs, RenderConfig, RenderFn, RenderResult } from "./pure";
export { cleanup, render };
export { cleanup, render, renderDirective };

page.extend({
render,
renderDirective,
[Symbol.for("vitest:component-cleanup")]: cleanup,
});

Expand All @@ -16,5 +17,6 @@ beforeEach(async () => {
declare module "vitest/browser" {
interface BrowserPage {
render: typeof render;
renderDirective: typeof renderDirective;
}
}
121 changes: 120 additions & 1 deletion src/pure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ import type {
Provider,
Type,
} from "@angular/core";
import { inputBinding, isSignal, outputBinding } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
inputBinding,
isSignal,
outputBinding,
} from "@angular/core";
import {
type ComponentFixture,
ɵgetCleanupHook as getCleanupHook,
TestBed,
} from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import {
provideRouter,
Router,
Expand Down Expand Up @@ -351,6 +358,118 @@ export async function render<T>(
};
}

export interface DirectiveRenderOptions {
/** Template to render the directive in. Must include the directive selector. */
template: string;

/** Host component input values to pass and make reactive. */
hostProps?: Record<string, unknown>;

/** Additional imports for the test module. */
imports?: Type<unknown>[];

/** Additional providers for the test module. */
providers?: Provider[];

/** The base element for screen queries. Defaults to document.body. */
baseElement?: HTMLElement;
}

export interface DirectiveRenderResult<T> extends LocatorSelectors {
container: HTMLElement;
baseElement: HTMLElement;
/**
* The host component's fixture.
*/
fixture: ComponentFixture<unknown>;
/**
* Instance of the tested directive.
*/
directiveInstance: T;
/**
* Locator scoped to the host element where the directive is applied.
*/
locator: Locator;
/**
* Debug function for the directive's element.
*/
debug(
el?: HTMLElement | HTMLElement[] | Locator | Locator[],
maxLength?: number,
options?: PrettyDOMOptions,
): void;
}

/**
* Renders a directive for testing with Vitest Browser Mode.
*
* @param directiveClass - The directive class to test
* @param options - Configuration including the template where the directive is applied
* @returns A render result with fixture, directive instance, and query methods
*
* @example
* ```typescript
* // Basic directive test
* const { directiveInstance, locator } = await renderDirective(HighlightDirective, {
* template: `<div appHighlight>Test</div>`,
* });
*
* // With host input binding
* const { locator } = await renderDirective(HighlightDirective, {
* template: `<div [appHighlight]="color" (blurred)="onClick($event)">Test</div>`,
* hostProps: { color: 'red', onClick: vi.fn() },
* imports: [JsonPipe], // extra imports for template
* });
* ```
*/
export async function renderDirective<T>(
directiveClass: Type<T>,
options: DirectiveRenderOptions,
): Promise<DirectiveRenderResult<T>> {
const baseElement = options.baseElement || document.body;
const imports = [directiveClass, ...(options.imports || [])];
const providers = [...(options.providers || [])];
const hostProps = options.hostProps || {};
@Component({
selector: "test-host",
imports,
template: options.template,
changeDetection: ChangeDetectionStrategy.OnPush,
})
class TestHostComponent {
constructor() {
if (options.hostProps) {
Object.assign(this, hostProps);
}
}
}

const { fixture, container, locator, debug } = await render(
TestHostComponent,
{
providers,
baseElement,
},
);
const directiveDE = fixture.debugElement.query(By.directive(directiveClass));
if (!directiveDE) {
throw new Error(
`[renderDirective] Could not find directive ${directiveClass.name} in template. ` +
`Make sure the template includes the directive selector and it is imported.`,
);
}
return {
container,
baseElement,
fixture,
locator,
directiveInstance: directiveDE.injector.get(directiveClass) as T,
debug: (el = container, maxLength?: number, opts?: PrettyDOMOptions) =>
debug(el, maxLength, opts),
...getElementLocatorSelectors(baseElement),
};
}

export function cleanup(shouldTeardown = false) {
return getCleanupHook(shouldTeardown)();
}
Expand Down
13 changes: 13 additions & 0 deletions test/directives/change-class.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Directive, input, output } from '@angular/core';

@Directive({
selector: '[test]',
host: {
'[class]': 'className()',
'(blur)': 'blurred.emit($event)',
},
})
export class ChangeClass {
className = input<string>();
blurred = output<FocusEvent>();
}
33 changes: 33 additions & 0 deletions test/render-directive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { signal } from '@angular/core';
import { userEvent } from 'vitest/browser';
import { renderDirective } from '../src';
import { ChangeClass } from './directives/change-class';

test('renders directive', async () => {
const className = signal('test');
const { getByText, fixture } = await renderDirective(ChangeClass, {
template: `<button test [className]="test()" (blurred)="onBlur($event)">Test</button>`,
hostProps: {
test: className,
},
});
expect(getByText('Test')).toHaveClass('test');

className.set('changed');
await fixture.whenStable();
await expect.element(getByText('Test')).toHaveClass('changed');
});

test('renders directive and emits outputs', async () => {
const blurredSpy = vi.fn();
const { getByText } = await renderDirective(ChangeClass, {
template: `<button test (blurred)="onBlur($event)">Test</button>`,
hostProps: {
onBlur: blurredSpy,
},
});
await userEvent.keyboard('{Tab}');
await expect.element(getByText('Test')).toHaveFocus();
await userEvent.keyboard('{Tab}');
expect(blurredSpy).toHaveBeenCalled();
});
Loading