-
Notifications
You must be signed in to change notification settings - Fork 88
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
[Shared Resources] Add JS API tests (#1954)
Co-authored-by: Jonny Gerig Meyer <jonny@oddbird.net> Co-authored-by: James Stuckey Weber <jamesnw@gmail.com>
- Loading branch information
1 parent
1f6ed61
commit f013971
Showing
2 changed files
with
341 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
})); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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/ | ||
); | ||
}); | ||
}); |