Skip to content

feat(vitest): vitest browser mode #588

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 5 commits into
base: main
Choose a base branch
from
Draft
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/evil-comics-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'sv': patch
---

feat(vitest): support vite browser mode
3 changes: 2 additions & 1 deletion packages/addons/tailwindcss/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ const options = defineAddonOptions({
type: 'multiselect',
question: 'Which plugins would you like to add?',
options: plugins.map((p) => ({ value: p.id, label: p.id, hint: p.package })),
default: []
default: [],
required: false
}
});

Expand Down
177 changes: 86 additions & 91 deletions packages/addons/vitest-addon/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import { dedent, defineAddon, log } from '@sveltejs/cli-core';
import { array, common, exports, functions, imports, object } from '@sveltejs/cli-core/js';
import { dedent, defineAddon, defineAddonOptions, log } from '@sveltejs/cli-core';
import { array, exports, functions, object } from '@sveltejs/cli-core/js';
import { parseJson, parseScript } from '@sveltejs/cli-core/parsers';

const options = defineAddonOptions({
usages: {
question: 'What do you want to use vitest for?',
type: 'multiselect',
default: ['unit', 'component'],
options: [
{ value: 'unit', label: 'unit testing' },
{ value: 'component', label: 'component testing' }
],
required: true
}
});

export default defineAddon({
id: 'vitest',
shortDescription: 'unit testing',
homepage: 'https://vitest.dev',
options: {},
run: ({ sv, typescript, kit }) => {
options,
run: ({ sv, typescript, kit, options }) => {
const ext = typescript ? 'ts' : 'js';
const unitTesting = options.usages.includes('unit');
const componentTesting = options.usages.includes('component');

sv.devDependency('vitest', '^3.2.3');
sv.devDependency('@testing-library/svelte', '^5.2.4');
sv.devDependency('@testing-library/jest-dom', '^6.6.3');
sv.devDependency('jsdom', '^26.0.0');

if (componentTesting) {
sv.devDependency('@vitest/browser', '^3.2.3');
sv.devDependency('vitest-browser-svelte', '^0.1.0');
sv.devDependency('playwright', '^1.0.0');
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would suggest picking the highest available playwright minor here, or at least the one that was available when vitest browser mode was introduced, i am not sure playwright 1.0.0 is actually compatible with it (playwright does breaking changes in minors unfortunately)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it matter though? Users should always get the latest minor installed, assuming they havn't used playwright in their project before


sv.file('package.json', (content) => {
const { data, generateCode } = parseJson(content);
Expand All @@ -28,108 +46,84 @@ export default defineAddon({
return generateCode();
});

sv.file(`src/demo.spec.${ext}`, (content) => {
if (content) return content;

return dedent`
import { describe, it, expect } from 'vitest';

describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
`;
});

if (kit) {
sv.file(`${kit.routesDirectory}/page.svelte.test.${ext}`, (content) => {
if (unitTesting) {
sv.file(`src/demo.spec.${ext}`, (content) => {
if (content) return content;

return dedent`
import { describe, test, expect } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import Page from './+page.svelte';
import { describe, it, expect } from 'vitest';

describe('/+page.svelte', () => {
test('should render h1', () => {
render(Page);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
});
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
`;
});
`;
});
} else {
sv.file(`src/App.svelte.test.${ext}`, (content) => {
}

if (componentTesting) {
const fileName = kit
? `${kit.routesDirectory}/page.svelte.test.${ext}`
: `src/App.svelte.test.${ext}`;

sv.file(fileName, (content) => {
if (content) return content;

return dedent`
import { describe, test, expect } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/svelte';
import App from './App.svelte';

describe('App.svelte', () => {
test('should render h1', () => {
render(App);
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
import { page } from '@vitest/browser/context';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
${kit ? "import Page from './+page.svelte';" : "import App from './App.svelte';"}

describe('${kit ? '/+page.svelte' : 'App.svelte'}', () => {
it('should render h1', async () => {
render(${kit ? 'Page' : 'App'});

const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});
`;
});
}

sv.file(`vitest-setup-client.${ext}`, (content) => {
if (content) return content;

return dedent`
import '@testing-library/jest-dom/vitest';
import { vi } from 'vitest';

// required for svelte5 + jsdom as jsdom does not support matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
enumerable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})

// add more mocks here if you need them
sv.file(`vitest-setup-client.${ext}`, (content) => {
if (content) return content;

return dedent`
/// <reference types="@vitest/browser/matchers" />
/// <reference types="@vitest/browser/providers/playwright" />
`;
});
});
}

sv.file(`vite.config.${ext}`, (content) => {
const { ast, generateCode } = parseScript(content);

imports.addNamed(ast, '@testing-library/svelte/vite', { svelteTesting: 'svelteTesting' });

const clientObjectExpression = object.create({
extends: common.createLiteral(`./vite.config.${ext}`),
plugins: common.expressionFromString('[svelteTesting()]'),
test: object.create({
name: common.createLiteral('client'),
environment: common.createLiteral('jsdom'),
clearMocks: common.expressionFromString('true'),
include: common.expressionFromString("['src/**/*.svelte.{test,spec}.{js,ts}']"),
exclude: common.expressionFromString("['src/lib/server/**']"),
setupFiles: common.expressionFromString(`['./vitest-setup-client.${ext}']`)
})
const clientObjectExpression = object.createFromPrimitives({
extends: `./vite.config.${ext}`,
test: {
name: 'client',
environment: 'browser',
browser: {
enabled: true,
provider: 'playwright',
instances: [{ browser: 'chromium' }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**'],
setupFiles: [`./vitest-setup-client.${ext}`]
}
});
const serverObjectExpression = object.create({
extends: common.createLiteral(`./vite.config.${ext}`),
test: object.create({
name: common.createLiteral('server'),
environment: common.createLiteral('node'),
include: common.expressionFromString("['src/**/*.{test,spec}.{js,ts}']"),
exclude: common.expressionFromString("['src/**/*.svelte.{test,spec}.{js,ts}']")
})

const serverObjectExpression = object.createFromPrimitives({
extends: `./vite.config.${ext}`,
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
});

const defineConfigFallback = functions.call('defineConfig', []);
Expand All @@ -142,8 +136,9 @@ export default defineAddon({
const testObject = object.property(vitestConfig, 'test', object.createEmpty());

const workspaceArray = object.property(testObject, 'projects', array.createEmpty());
array.push(workspaceArray, clientObjectExpression);
array.push(workspaceArray, serverObjectExpression);

if (componentTesting) array.push(workspaceArray, clientObjectExpression);
if (unitTesting) array.push(workspaceArray, serverObjectExpression);

return generateCode();
});
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/commands/add/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,7 +508,7 @@ export async function runAddCommand(
answer = await p.multiselect({
message,
initialValues: question.default,
required: false,
required: question.required,
options: question.options
});
}
Expand Down
1 change: 1 addition & 0 deletions packages/core/addon/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type MultiSelectQuestion<Value = any> = {
type: 'multiselect';
default: Value[];
options: Array<{ value: Value; label?: string; hint?: string }>;
required: boolean;
};

export type BaseQuestion = {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/tests/js/object/create-from-primitives/output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const empty = {};

const created = {
foo: 1,
bar: 'string',
object: { foo: 'hello', nested: { bar: 'world' } },
array: [
123,
'hello',
{ foo: 'bar', bool: true },
[456, '789']
]
};
22 changes: 22 additions & 0 deletions packages/core/tests/js/object/create-from-primitives/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { variables, object, type AstTypes } from '@sveltejs/cli-core/js';

export function run(ast: AstTypes.Program): void {
const emptyObject = object.createFromPrimitives({});
const emptyVariable = variables.declaration(ast, 'const', 'empty', emptyObject);
ast.body.push(emptyVariable);

const createdObject = object.createFromPrimitives({
foo: 1,
bar: 'string',
baz: undefined,
object: {
foo: 'hello',
nested: {
bar: 'world'
}
},
array: [123, 'hello', { foo: 'bar', bool: true }, [456, '789']]
});
const createdVariable = variables.declaration(ast, 'const', 'created', createdObject);
ast.body.push(createdVariable);
}
34 changes: 34 additions & 0 deletions packages/core/tooling/js/object.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { Expression } from 'estree';
import type { AstTypes } from '../index.ts';
import { array, common } from './index.ts';

export function property<T extends AstTypes.Expression | AstTypes.Identifier>(
ast: AstTypes.ObjectExpression,
Expand Down Expand Up @@ -103,6 +105,38 @@ export function create<T extends AstTypes.Expression>(
return objExpression;
}

type ObjectPrimitiveValues = string | number | boolean | undefined;
type ObjectValues = ObjectPrimitiveValues | Object | ObjectValues[];
type Object = { [property: string]: ObjectValues };

// todo: potentially make this the default `create` method in the future
export function createFromPrimitives(obj: Object): AstTypes.ObjectExpression {
const objExpression = createEmpty();

const getExpression = (value: ObjectValues) => {
let expression: Expression;
if (Array.isArray(value)) {
expression = array.createEmpty();
for (const v of value) {
array.push(expression, getExpression(v));
}
} else if (typeof value === 'object' && value !== null) {
expression = createFromPrimitives(value);
} else {
expression = common.createLiteral(value);
}
return expression;
};

for (const [prop, value] of Object.entries(obj)) {
if (value === undefined) continue;

property(objExpression, prop, getExpression(value));
}

return objExpression;
}

export function createEmpty(): AstTypes.ObjectExpression {
const objectExpression: AstTypes.ObjectExpression = {
type: 'ObjectExpression',
Expand Down
Loading