Skip to content
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

[Shared Resources] Add JS API tests #1954

Merged
merged 24 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
134 changes: 134 additions & 0 deletions js-api-spec/compiler.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright 2023 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import type {AsyncCompiler, Compiler, CompileResult} from 'sass';
import {initAsyncCompiler, initCompiler} from 'sass';

import {
asyncImporters,
functions,
getLogger,
getSlowImporter,
importers,
} from './compiler.test';
import {sandbox} from './sandbox';
import {URL} from './utils';

describe('Compiler', () => {
let compiler: Compiler;

beforeEach(() => {
compiler = initCompiler();
});

afterEach(() => {
compiler.dispose();
});

describe('compile', () => {
it('performs complete compilations', () =>
sandbox(dir => {
const logger = getLogger();
dir.write({'input.scss': '@import "bar"; .fn {value: foo(bar)}'});
const result = compiler.compile(dir('input.scss'), {
importers,
functions,
logger,
});
expect(result.css).toEqualIgnoringWhitespace(
'.import {value: bar;} .fn {value: "bar";}'
);
expect(logger.debug).toHaveBeenCalledTimes(1);
}));

it('performs compilations in callbacks', () =>
sandbox(dir => {
dir.write({'input-nested.scss': 'x {y: z}'});
const nestedImporter = {
canonicalize: () => new URL('foo:bar'),
load: () => ({
contents: compiler.compile(dir('input-nested.scss')).css,
syntax: 'scss' as const,
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: Rather than as const, I think it's cleaner to add a type declaration to the variable itself (const nestedImporter: Importer) so that it infers the correct types for all its components. Similarly for the top-level fields in compiler.test.ts.

Copy link
Contributor

Choose a reason for hiding this comment

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

Updated in 0a14b6f.

}),
};
dir.write({'input.scss': '@import "nested"; a {b: c}'});
const result = compiler.compile(dir('input.scss'), {
importers: [nestedImporter],
});
expect(result.css).toEqualIgnoringWhitespace('x {y: z;} a {b: c;}');
}));

it('throws after being disposed', () =>
sandbox(dir => {
dir.write({'input.scss': '$a: b; c {d: $a}'});
compiler.dispose();
expect(() => compiler.compile(dir('input.scss'))).toThrowError();
}));
});
});

describe('AsyncCompiler', () => {
let compiler: AsyncCompiler;
const runs = 1000; // Number of concurrent compilations to run
Copy link
Contributor

Choose a reason for hiding this comment

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

Declare this in the test where it's used

Copy link
Contributor

Choose a reason for hiding this comment

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

Moved in 0a14b6f


beforeEach(async () => {
compiler = await initAsyncCompiler();
});

afterEach(async () => {
await compiler.dispose();
});

describe('compileAsync', () => {
it(
'handles multiple concurrent compilations',
() =>
sandbox(async dir => {
const logger = getLogger();
const compilations = Array(runs)
.fill(0)
.map((_, i) => {
const filename = `input-${i}.scss`;
dir.write({
[filename]: `@import "${i}"; .fn {value: foo(${i})}`,
});
return compiler.compileAsync(dir(filename), {
importers: asyncImporters,
functions,
logger,
});
});
Array.from(await Promise.all(compilations))
.map((result: CompileResult) => result.css)
.forEach((result, i) => {
expect(result).toEqualIgnoringWhitespace(
`.import {value: ${i};} .fn {value: "${i}";}`
);
});
expect(logger.debug).toHaveBeenCalledTimes(runs);
}),
40_000 // Increase timeout for slow CI
);

it('throws after being disposed', () =>
sandbox(async dir => {
dir.write({'input.scss': '$a: b; c {d: $a}'});
await compiler.dispose();
expect(() => compiler.compileAsync(dir('input.scss'))).toThrowError();
}));

it('waits for compilations to finish before disposing', () =>
sandbox(async dir => {
let completed = false;
dir.write({'input.scss': '@import "slow"'});
const compilation = compiler.compileAsync(dir('input.scss'), {
importers: [getSlowImporter(() => (completed = true))],
});
expect(completed).toBeFalse();
await compiler.dispose();
expect(completed).toBeTrue();
await expectAsync(compilation).toBeResolved();
}));
});
});
140 changes: 140 additions & 0 deletions js-api-spec/compiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright 2023 Google Inc. Use of this source code is governed by an
// MIT-style license that can be found in the LICENSE file or at
// https://opensource.org/licenses/MIT.

import type {AsyncCompiler, Compiler, CompileResult} from 'sass';
import {initAsyncCompiler, initCompiler, SassString} from 'sass';

import {spy, URL} from './utils';

export const functions = {
'foo($args)': (args: unknown) => new SassString(`${args}`),
};

export const importers = [
{
canonicalize: (url: string) => new URL(`u:${url}`),
load: (url: typeof URL) => ({
Copy link
Contributor

Choose a reason for hiding this comment

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

I think now that importers itself is typed you don't need to type the parameters here (or in asyncImporters).

Copy link
Contributor

Choose a reason for hiding this comment

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

Updated

contents: `.import {value: ${url.pathname}} @debug "imported";`,
syntax: 'scss' as const,
}),
},
];

export const asyncImporters = [
{
canonicalize: (url: string) =>
Promise.resolve(importers[0].canonicalize(url)),
load: (url: typeof URL) => Promise.resolve(importers[0].load(url)),
},
];

export const getLogger = () => ({debug: spy(() => {})});

/* A slow importer that executes a callback after a delay */
export const getSlowImporter = (callback: () => void) => ({
Copy link
Contributor

Choose a reason for hiding this comment

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

Declare functions as export function, and don't forget to include a return type.

Copy link
Contributor

Choose a reason for hiding this comment

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

Resolved in 6dcdd38

canonicalize: async () => new URL('foo:bar'),
load: async () => {
await new Promise(resolve => setTimeout(resolve, 100));
Copy link
Contributor

Choose a reason for hiding this comment

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

Rather than using a timeout when testing that concurrent systems wait for a given event, it's usually better to have a promise whose resolution you control explicitly so you can guarantee it won't finish until you want it to (and then you don't end up writing tests that take a lot longer than they need to because of delays). You can run await Promise(resolve => setTimeout(resolve, 0)) to force all outstanding non-time-based tasks to complete, then resolve the promise and verify that the behavior happens as expected.

Copy link
Contributor

Choose a reason for hiding this comment

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

Resolved in 6dcdd38

callback();
return {contents: '', syntax: 'scss' as const};
},
});

describe('Compiler', () => {
let compiler: Compiler;

beforeEach(() => {
compiler = initCompiler();
});

afterEach(() => {
compiler.dispose();
});

describe('compileString', () => {
it('performs complete compilations', () => {
const logger = getLogger();
const result = compiler.compileString(
'@import "bar"; .fn {value: foo(baz)}',
{importers, functions, logger}
);
Comment on lines +75 to +78
Copy link
Contributor Author

@jerivas jerivas Nov 17, 2023

Choose a reason for hiding this comment

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

we should also verify that at least two concurrent compilations work for the sync compiler, since it's theoretically possible to start a second one in a callback from the first.

I'm not sure I 100% understand the request, but this is my attempt to do it by using importers and functions similar to the async case

Copy link
Contributor

Choose a reason for hiding this comment

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

I want to testing running multiple synchronous compilations at the same time. This means you'll need to start a second compilation within a callback for the first one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added in 7c17ae5

expect(result.css).toEqualIgnoringWhitespace(
'.import {value: bar;} .fn {value: "baz";}'
);
expect(logger.debug).toHaveBeenCalledTimes(1);
});

it('performs compilations in callbacks', () => {
const nestedImporter = {
canonicalize: () => new URL('foo:bar'),
load: () => ({
contents: compiler.compileString('x {y: z}').css,
syntax: 'scss' as const,
}),
};
const result = compiler.compileString('@import "nested"; a {b: c}', {
importers: [nestedImporter],
});
expect(result.css).toEqualIgnoringWhitespace('x {y: z;} a {b: c;}');
});

it('throws after being disposed', () => {
compiler.dispose();
expect(() => compiler.compileString('$a: b; c {d: $a}')).toThrowError();
});
});
});

describe('AsyncCompiler', () => {
let compiler: AsyncCompiler;
const runs = 1000; // Number of concurrent compilations to run

beforeEach(async () => {
compiler = await initAsyncCompiler();
});

afterEach(async () => {
await compiler.dispose();
});

describe('compileStringAsync', () => {
it('handles multiple concurrent compilations', async () => {
const logger = getLogger();
const compilations = Array(runs)
.fill(0)
.map((_, i) =>
compiler.compileStringAsync(
`@import "${i}"; .fn {value: foo(${i})}`,
{importers: asyncImporters, functions, logger}
)
);
Array.from(await Promise.all(compilations))
.map((result: CompileResult) => result.css)
.forEach((result, i) => {
expect(result).toEqualIgnoringWhitespace(
`.import {value: ${i};} .fn {value: "${i}";}`
);
});
expect(logger.debug).toHaveBeenCalledTimes(runs);
}, 15_000); // Increase timeout for slow CI

it('throws after being disposed', async () => {
await compiler.dispose();
expect(() =>
compiler.compileStringAsync('$a: b; c {d: $a}')
).toThrowError();
});

it('waits for compilations to finish before disposing', async () => {
let completed = false;
const compilation = compiler.compileStringAsync('@import "slow"', {
importers: [getSlowImporter(() => (completed = true))],
});
expect(completed).toBeFalse();
await compiler.dispose();
expect(completed).toBeTrue();
await expectAsync(compilation).toBeResolved();
});
});
});