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 all 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
140 changes: 140 additions & 0 deletions js-api-spec/compiler.node.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright 2024 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, Importer} from 'sass';
import {initAsyncCompiler, initCompiler} from 'sass';

import {
asyncImporters,
functions,
getLogger,
getTriggeredImporter,
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: Importer = {
canonicalize: () => new URL('foo:bar'),
load: () => ({
contents: compiler.compile(dir('input-nested.scss')).css,
syntax: 'scss',
}),
};
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;

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

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

describe('compileAsync', () => {
it(
'handles multiple concurrent compilations',
() =>
sandbox(async dir => {
const runs = 1000; // Number of concurrent compilations to run
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 {importer, triggerComplete} = getTriggeredImporter(
() => (completed = true)
);
const compilation = compiler.compileAsync(dir('input.scss'), {
importers: [importer],
});
const disposalPromise = compiler.dispose();
expect(completed).toBeFalse();
triggerComplete();

await disposalPromise;
expect(completed).toBeTrue();
await expectAsync(compilation).toBeResolved();
}));
});
});
201 changes: 201 additions & 0 deletions js-api-spec/compiler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright 2024 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 {CompileResult, Importer} from 'sass';
import {
initAsyncCompiler,
initCompiler,
SassString,
AsyncCompiler,
Compiler,
} from 'sass';

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

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

export const importers: Array<Importer> = [
{
canonicalize: url => new URL(`u:${url}`),
load: url => ({
contents: `.import {value: ${url.pathname}} @debug "imported";`,
syntax: 'scss' as const,
}),
},
];

export const asyncImporters: Array<Importer> = [
{
canonicalize: url => Promise.resolve(new URL(`u:${url}`)),
load: url => Promise.resolve(importers[0].load(url)),
},
];

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

/* A triggered importer that executes a callback after a trigger is called */
export function getTriggeredImporter(callback: () => void): {
importer: Importer;
triggerComplete: () => void;
} {
let promiseResolve: (value: unknown) => void;
const awaitedPromise = new Promise(resolve => {
promiseResolve = resolve;
});
return {
importer: {
canonicalize: async () => new URL('foo:bar'),
load: async () => {
await awaitedPromise;
callback();
return {contents: '', syntax: 'scss' as const};
},
},
triggerComplete: () => promiseResolve(undefined),
};
}

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: Importer = {
canonicalize: () => new URL('foo:bar'),
load: () => ({
contents: compiler.compileString('x {y: z}').css,
syntax: 'scss',
}),
};
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();
});

it('succeeds after a compilation failure', () => {
expect(() => compiler.compileString('a')).toThrowSassException({
includes: 'expected "{"',
});
const result2 = compiler.compileString('x {y: z}');
expect(result2.css).toEqualIgnoringWhitespace('x {y: z;}');
});
});

it('errors if constructor invoked directly', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Blank line before test case

Copy link
Contributor

Choose a reason for hiding this comment

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

Added in 76e676a

// Strip types to allow calling private constructor.
class Untyped {}
const UntypedCompiler = Compiler as unknown as typeof Untyped;
expect(() => new UntypedCompiler()).toThrowError(
/Compiler can not be directly constructed/
);
});
});

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 {importer, triggerComplete} = getTriggeredImporter(
() => (completed = true)
);
const compilation = compiler.compileStringAsync('@import "slow"', {
importers: [importer],
});

const disposalPromise = compiler.dispose();
expect(completed).toBeFalse();
triggerComplete();

await disposalPromise;
expect(completed).toBeTrue();
await expectAsync(compilation).toBeResolved();
});

it('succeeds after a compilation failure', async () => {
expectAsync(
async () => await compiler.compileStringAsync('a')
).toThrowSassException({
includes: 'expected "{"',
});

const result2 = await compiler.compileStringAsync('x {y: z}');
expect(result2.css).toEqualIgnoringWhitespace('x {y: z;}');
});
});

it('errors if constructor invoked directly', () => {
// Strip types to allow calling private constructor.
class Untyped {}
const UntypedAsyncCompiler = AsyncCompiler as unknown as typeof Untyped;
expect(() => new UntypedAsyncCompiler()).toThrowError(
/AsyncCompiler can not be directly constructed/
);
});
});