Skip to content

Commit

Permalink
[Shared Resources] Add JS API tests (#1954)
Browse files Browse the repository at this point in the history
Co-authored-by: Jonny Gerig Meyer <jonny@oddbird.net>
Co-authored-by: James Stuckey Weber <jamesnw@gmail.com>
  • Loading branch information
3 people authored Jan 18, 2024
1 parent 1f6ed61 commit f013971
Show file tree
Hide file tree
Showing 2 changed files with 341 additions and 0 deletions.
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}
);
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', () => {
// 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/
);
});
});

0 comments on commit f013971

Please sign in to comment.