mirror of
https://github.com/CommunitySolidServer/CommunitySolidServer.git
synced 2024-10-03 14:55:10 +00:00
feat: Incorporate server-side representation quality.
Closes https://github.com/solid/community-server/issues/467
This commit is contained in:
parent
09ae959333
commit
8cd3f7d2e5
@ -77,7 +77,7 @@ export class RepresentationConvertingStore<T extends ResourceStore = ResourceSto
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if there is a result if we try to map the preferences to the content-type
|
// Check if there is a result if we try to map the preferences to the content-type
|
||||||
return matchingMediaTypes(preferences, [ contentType ]).length > 0;
|
return matchingMediaTypes(preferences.type, { [contentType]: 1 }).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -46,13 +46,7 @@ export class ChainedConverter extends TypedRepresentationConverter {
|
|||||||
public async canHandle(input: RepresentationConverterArgs): Promise<void> {
|
public async canHandle(input: RepresentationConverterArgs): Promise<void> {
|
||||||
// We assume a chain can be constructed, otherwise there would be a configuration issue
|
// We assume a chain can be constructed, otherwise there would be a configuration issue
|
||||||
// So we only check if the input can be parsed and the preferred type can be written
|
// So we only check if the input can be parsed and the preferred type can be written
|
||||||
const inTypes = this.getAcceptableTypes(await this.first.getInputTypes());
|
supportsConversion(input, await this.first.getInputTypes(), await this.last.getOutputTypes());
|
||||||
const outTypes = this.getAcceptableTypes(await this.last.getOutputTypes());
|
|
||||||
supportsConversion(input, inTypes, outTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getAcceptableTypes(preferences: ValuePreferences): string[] {
|
|
||||||
return Object.keys(preferences).filter((name): boolean => preferences[name] > 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handle(input: RepresentationConverterArgs): Promise<Representation> {
|
public async handle(input: RepresentationConverterArgs): Promise<Representation> {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import type { RepresentationPreferences } from '../../ldp/representation/RepresentationPreferences';
|
import type { ValuePreferences } from '../../ldp/representation/RepresentationPreferences';
|
||||||
import { INTERNAL_ALL } from '../../util/ContentTypes';
|
import { INTERNAL_ALL } from '../../util/ContentTypes';
|
||||||
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
|
import { BadRequestHttpError } from '../../util/errors/BadRequestHttpError';
|
||||||
import { InternalServerError } from '../../util/errors/InternalServerError';
|
import { InternalServerError } from '../../util/errors/InternalServerError';
|
||||||
@ -12,43 +12,42 @@ import type { RepresentationConverterArgs } from './RepresentationConverter';
|
|||||||
* Since more specific media ranges override less specific ones,
|
* Since more specific media ranges override less specific ones,
|
||||||
* this will be ignored if there is a specific internal type preference.
|
* this will be ignored if there is a specific internal type preference.
|
||||||
*
|
*
|
||||||
* @param preferences - Preferences for output type.
|
* @param preferredTypes - Preferences for output type.
|
||||||
* @param types - Media types to compare to the preferences.
|
* @param availableTypes - Media types to compare to the preferences.
|
||||||
*
|
*
|
||||||
* @throws BadRequestHttpError
|
* @throws BadRequestHttpError
|
||||||
* If the type preferences are undefined or if there are duplicate preferences.
|
* If the type preferences are undefined or if there are duplicate preferences.
|
||||||
*
|
*
|
||||||
* @returns The weighted and filtered list of matching types.
|
* @returns The weighted and filtered list of matching types.
|
||||||
*/
|
*/
|
||||||
export const matchingMediaTypes = (preferences: RepresentationPreferences, available: string[]):
|
export const matchingMediaTypes = (preferredTypes: ValuePreferences = {}, availableTypes: ValuePreferences = {}):
|
||||||
string[] => {
|
string[] => {
|
||||||
const preferredTypes = preferences.type;
|
// No preference means anything is acceptable
|
||||||
if (!preferredTypes || Object.keys(preferredTypes).length === 0) {
|
const preferred = { ...preferredTypes };
|
||||||
throw new BadRequestHttpError('Output type required for conversion.');
|
if (Object.keys(preferredTypes).length === 0) {
|
||||||
}
|
preferred['*/*'] = 1;
|
||||||
|
|
||||||
// Prevent accidental use of internal types
|
// Prevent accidental use of internal types
|
||||||
if (!preferredTypes[INTERNAL_ALL]) {
|
} else if (!(INTERNAL_ALL in preferred)) {
|
||||||
preferredTypes[INTERNAL_ALL] = 0;
|
preferred[INTERNAL_ALL] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RFC 7231
|
// RFC 7231
|
||||||
// Media ranges can be overridden by more specific media ranges or
|
// Media ranges can be overridden by more specific media ranges or
|
||||||
// specific media types. If more than one media range applies to a
|
// specific media types. If more than one media range applies to a
|
||||||
// given type, the most specific reference has precedence.
|
// given type, the most specific reference has precedence.
|
||||||
const weightedSupported = available.map((type): [string, number] => {
|
const weightedSupported = Object.entries(availableTypes).map(([ type, quality ]): [string, number] => {
|
||||||
const match = /^([^/]+)\/([^\s;]+)/u.exec(type);
|
const match = /^([^/]+)\/([^\s;]+)/u.exec(type);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new InternalServerError(`Unexpected type preference: ${type}`);
|
throw new InternalServerError(`Unexpected type preference: ${type}`);
|
||||||
}
|
}
|
||||||
const [ , main, sub ] = match;
|
const [ , main, sub ] = match;
|
||||||
const weight =
|
const weight =
|
||||||
preferredTypes[type] ??
|
preferred[type] ??
|
||||||
preferredTypes[`${main}/${sub}`] ??
|
preferred[`${main}/${sub}`] ??
|
||||||
preferredTypes[`${main}/*`] ??
|
preferred[`${main}/*`] ??
|
||||||
preferredTypes['*/*'] ??
|
preferred['*/*'] ??
|
||||||
0;
|
0;
|
||||||
return [ type, weight ];
|
return [ type, weight * quality ];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return all non-zero preferences in descending order of weight
|
// Return all non-zero preferences in descending order of weight
|
||||||
@ -96,16 +95,16 @@ export const matchesMediaType = (mediaA: string, mediaB: string): boolean => {
|
|||||||
* @param supportedIn - Media types that can be parsed by the converter.
|
* @param supportedIn - Media types that can be parsed by the converter.
|
||||||
* @param supportedOut - Media types that can be produced by the converter.
|
* @param supportedOut - Media types that can be produced by the converter.
|
||||||
*/
|
*/
|
||||||
export const supportsConversion = (request: RepresentationConverterArgs, supportedIn: string[],
|
export const supportsConversion = (request: RepresentationConverterArgs, supportedIn: ValuePreferences,
|
||||||
supportedOut: string[]): void => {
|
supportedOut: ValuePreferences): void => {
|
||||||
const inType = request.representation.metadata.contentType;
|
const inType = request.representation.metadata.contentType;
|
||||||
if (!inType) {
|
if (!inType) {
|
||||||
throw new BadRequestHttpError('Input type required for conversion.');
|
throw new BadRequestHttpError('No content type indicated on request.');
|
||||||
}
|
}
|
||||||
if (!supportedIn.some((type): boolean => matchesMediaType(inType, type))) {
|
if (!Object.keys(supportedIn).some((type): boolean => matchesMediaType(inType, type)) ||
|
||||||
throw new NotImplementedHttpError(`Can only convert from ${supportedIn} to ${supportedOut}.`);
|
matchingMediaTypes(request.preferences.type, supportedOut).length === 0) {
|
||||||
}
|
throw new NotImplementedHttpError(
|
||||||
if (matchingMediaTypes(request.preferences, supportedOut).length <= 0) {
|
`Can only convert from ${Object.keys(supportedIn)} to ${Object.keys(supportedOut)}.`,
|
||||||
throw new NotImplementedHttpError(`Can only convert from ${supportedIn} to ${supportedOut}.`);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -29,8 +29,8 @@ export class QuadToRdfConverter extends TypedRepresentationConverter {
|
|||||||
return this.quadsToRdf(input.representation, input.preferences);
|
return this.quadsToRdf(input.representation, input.preferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async quadsToRdf(quads: Representation, preferences: RepresentationPreferences): Promise<Representation> {
|
private async quadsToRdf(quads: Representation, { type }: RepresentationPreferences): Promise<Representation> {
|
||||||
const contentType = matchingMediaTypes(preferences, await rdfSerializer.getContentTypes())[0];
|
const contentType = matchingMediaTypes(type, await this.getOutputTypes())[0];
|
||||||
const metadata = new RepresentationMetadata(quads.metadata, { [CONTENT_TYPE]: contentType });
|
const metadata = new RepresentationMetadata(quads.metadata, { [CONTENT_TYPE]: contentType });
|
||||||
return {
|
return {
|
||||||
binary: true,
|
binary: true,
|
||||||
|
@ -23,6 +23,6 @@ export abstract class TypedRepresentationConverter extends RepresentationConvert
|
|||||||
public async canHandle(args: RepresentationConverterArgs): Promise<void> {
|
public async canHandle(args: RepresentationConverterArgs): Promise<void> {
|
||||||
const types = [ this.getInputTypes(), this.getOutputTypes() ];
|
const types = [ this.getInputTypes(), this.getOutputTypes() ];
|
||||||
const [ inputTypes, outputTypes ] = await Promise.all(types);
|
const [ inputTypes, outputTypes ] = await Promise.all(types);
|
||||||
supportsConversion(args, Object.keys(inputTypes), Object.keys(outputTypes));
|
supportsConversion(args, inputTypes, outputTypes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ describe('A RepresentationConvertingStore', (): void => {
|
|||||||
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
|
expect(source.getRepresentation).toHaveBeenCalledTimes(1);
|
||||||
expect(source.getRepresentation).toHaveBeenLastCalledWith(
|
expect(source.getRepresentation).toHaveBeenLastCalledWith(
|
||||||
{ path: 'path' },
|
{ path: 'path' },
|
||||||
{ type: { 'application/*': 0, 'text/turtle': 1, 'internal/*': 0 }},
|
{ type: { 'application/*': 0, 'text/turtle': 1 }},
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
expect(outConverter.handleSafe).toHaveBeenCalledTimes(0);
|
expect(outConverter.handleSafe).toHaveBeenCalledTimes(0);
|
||||||
@ -70,7 +70,7 @@ describe('A RepresentationConvertingStore', (): void => {
|
|||||||
expect(outConverter.handleSafe).toHaveBeenLastCalledWith({
|
expect(outConverter.handleSafe).toHaveBeenLastCalledWith({
|
||||||
identifier: { path: 'path' },
|
identifier: { path: 'path' },
|
||||||
representation: { data: 'data', metadata },
|
representation: { data: 'data', metadata },
|
||||||
preferences: { type: { 'text/plain': 1, 'text/turtle': 0, 'internal/*': 0 }},
|
preferences: { type: { 'text/plain': 1, 'text/turtle': 0 }},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import type {
|
|||||||
RepresentationPreferences,
|
RepresentationPreferences,
|
||||||
} from '../../../../src/ldp/representation/RepresentationPreferences';
|
} from '../../../../src/ldp/representation/RepresentationPreferences';
|
||||||
import { ChainedConverter } from '../../../../src/storage/conversion/ChainedConverter';
|
import { ChainedConverter } from '../../../../src/storage/conversion/ChainedConverter';
|
||||||
import { supportsConversion } from '../../../../src/storage/conversion/ConversionUtil';
|
|
||||||
import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter';
|
import type { RepresentationConverterArgs } from '../../../../src/storage/conversion/RepresentationConverter';
|
||||||
import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter';
|
import { TypedRepresentationConverter } from '../../../../src/storage/conversion/TypedRepresentationConverter';
|
||||||
import { CONTENT_TYPE } from '../../../../src/util/Vocabularies';
|
import { CONTENT_TYPE } from '../../../../src/util/Vocabularies';
|
||||||
@ -28,10 +27,6 @@ class DummyConverter extends TypedRepresentationConverter {
|
|||||||
return this.outTypes;
|
return this.outTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async canHandle(input: RepresentationConverterArgs): Promise<void> {
|
|
||||||
supportsConversion(input, Object.keys(this.inTypes), Object.keys(this.outTypes));
|
|
||||||
}
|
|
||||||
|
|
||||||
public async handle(input: RepresentationConverterArgs): Promise<Representation> {
|
public async handle(input: RepresentationConverterArgs): Promise<Representation> {
|
||||||
const metadata = new RepresentationMetadata(input.representation.metadata,
|
const metadata = new RepresentationMetadata(input.representation.metadata,
|
||||||
{ [CONTENT_TYPE]: Object.keys(input.preferences.type!)[0] });
|
{ [CONTENT_TYPE]: Object.keys(input.preferences.type!)[0] });
|
||||||
@ -85,7 +80,7 @@ describe('A ChainedConverter', (): void => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('errors if the end of the chain does not support the preferences.', async(): Promise<void> => {
|
it('errors if the end of the chain does not support the preferences.', async(): Promise<void> => {
|
||||||
delete preferences.type;
|
preferences.type = { 'abc/def': 1 };
|
||||||
await expect(converter.canHandle(args)).rejects.toThrow();
|
await expect(converter.canHandle(args)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
import type { Representation } from '../../../../src/ldp/representation/Representation';
|
import type { Representation } from '../../../../src/ldp/representation/Representation';
|
||||||
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
|
import { RepresentationMetadata } from '../../../../src/ldp/representation/RepresentationMetadata';
|
||||||
import type { RepresentationPreferences } from '../../../../src/ldp/representation/RepresentationPreferences';
|
import type {
|
||||||
|
ValuePreferences,
|
||||||
|
RepresentationPreferences,
|
||||||
|
} from '../../../../src/ldp/representation/RepresentationPreferences';
|
||||||
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
|
import type { ResourceIdentifier } from '../../../../src/ldp/representation/ResourceIdentifier';
|
||||||
import {
|
import {
|
||||||
matchesMediaType,
|
matchesMediaType,
|
||||||
@ -22,15 +25,19 @@ describe('ConversionUtil', (): void => {
|
|||||||
describe('#supportsConversion', (): void => {
|
describe('#supportsConversion', (): void => {
|
||||||
it('requires an input type.', async(): Promise<void> => {
|
it('requires an input type.', async(): Promise<void> => {
|
||||||
const preferences: RepresentationPreferences = {};
|
const preferences: RepresentationPreferences = {};
|
||||||
expect((): any => supportsConversion({ identifier, representation, preferences }, [ 'a/x' ], [ 'a/x' ]))
|
expect((): any => supportsConversion({ identifier, representation, preferences },
|
||||||
.toThrow('Input type required for conversion.');
|
{ 'a/x': 1 },
|
||||||
|
{ 'a/x': 1 }))
|
||||||
|
.toThrow('No content type indicated on request.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('requires a matching input type.', async(): Promise<void> => {
|
it('requires a matching input type.', async(): Promise<void> => {
|
||||||
metadata.contentType = 'a/x';
|
metadata.contentType = 'a/x';
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: RepresentationPreferences =
|
||||||
{ type: { 'b/x': 1 }};
|
{ type: { 'b/x': 1 }};
|
||||||
expect((): any => supportsConversion({ identifier, representation, preferences }, [ 'c/x' ], [ 'a/x' ]))
|
expect((): any => supportsConversion({ identifier, representation, preferences },
|
||||||
|
{ 'c/x': 1 },
|
||||||
|
{ 'a/x': 1 }))
|
||||||
.toThrow('Can only convert from c/x to a/x.');
|
.toThrow('Can only convert from c/x to a/x.');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,7 +45,9 @@ describe('ConversionUtil', (): void => {
|
|||||||
metadata.contentType = 'a/x';
|
metadata.contentType = 'a/x';
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: RepresentationPreferences =
|
||||||
{ type: { 'b/x': 1 }};
|
{ type: { 'b/x': 1 }};
|
||||||
expect((): any => supportsConversion({ identifier, representation, preferences }, [ 'a/x' ], [ 'c/x' ]))
|
expect((): any => supportsConversion({ identifier, representation, preferences },
|
||||||
|
{ 'a/x': 1 },
|
||||||
|
{ 'c/x': 1 }))
|
||||||
.toThrow('Can only convert from a/x to c/x.');
|
.toThrow('Can only convert from a/x to c/x.');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -46,58 +55,58 @@ describe('ConversionUtil', (): void => {
|
|||||||
metadata.contentType = 'a/x';
|
metadata.contentType = 'a/x';
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: RepresentationPreferences =
|
||||||
{ type: { 'b/x': 1 }};
|
{ type: { 'b/x': 1 }};
|
||||||
expect(supportsConversion({ identifier, representation, preferences }, [ 'a/x' ], [ 'b/x' ]))
|
expect(supportsConversion({ identifier, representation, preferences },
|
||||||
|
{ 'a/x': 1 },
|
||||||
|
{ 'b/x': 1 }))
|
||||||
.toBeUndefined();
|
.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('#matchingMediaTypes', (): void => {
|
describe('#matchingMediaTypes', (): void => {
|
||||||
it('requires type preferences.', async(): Promise<void> => {
|
it('returns the empty array if no preferences specified.', async(): Promise<void> => {
|
||||||
const preferences: RepresentationPreferences =
|
expect(matchingMediaTypes())
|
||||||
{};
|
.toEqual([]);
|
||||||
expect((): any => matchingMediaTypes(preferences, [ 'a/b' ]))
|
|
||||||
.toThrow('Output type required for conversion.');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns matching types if weight > 0.', async(): Promise<void> => {
|
it('returns matching types if weight > 0.', async(): Promise<void> => {
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0 };
|
||||||
{ type: { 'a/x': 1, 'b/x': 0.5, 'c/x': 0 }};
|
expect(matchingMediaTypes(preferences, { 'b/x': 1, 'c/x': 1 }))
|
||||||
expect(matchingMediaTypes(preferences, [ 'b/x', 'c/x' ]))
|
|
||||||
.toEqual([ 'b/x' ]);
|
.toEqual([ 'b/x' ]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sorts by descending weight.', async(): Promise<void> => {
|
it('sorts by descending weight.', async(): Promise<void> => {
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0.8 };
|
||||||
{ type: { 'a/x': 1, 'b/x': 0.5, 'c/x': 0.8 }};
|
expect(matchingMediaTypes(preferences, { 'a/x': 1, 'b/x': 1, 'c/x': 1 }))
|
||||||
expect(matchingMediaTypes(preferences, [ 'a/x', 'b/x', 'c/x' ]))
|
|
||||||
.toEqual([ 'a/x', 'c/x', 'b/x' ]);
|
.toEqual([ 'a/x', 'c/x', 'b/x' ]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('incorporates representation qualities when calculating weight.', async(): Promise<void> => {
|
||||||
|
const preferences: ValuePreferences = { 'a/x': 1, 'b/x': 0.5, 'c/x': 0.8 };
|
||||||
|
expect(matchingMediaTypes(preferences, { 'a/x': 0.1, 'b/x': 1, 'c/x': 0.6 }))
|
||||||
|
.toEqual([ 'b/x', 'c/x', 'a/x' ]);
|
||||||
|
});
|
||||||
|
|
||||||
it('errors if there invalid types.', async(): Promise<void> => {
|
it('errors if there invalid types.', async(): Promise<void> => {
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: ValuePreferences = { 'b/x': 1 };
|
||||||
{ type: { 'b/x': 1 }};
|
expect((): any => matchingMediaTypes(preferences, { noType: 1 }))
|
||||||
expect((): any => matchingMediaTypes(preferences, [ 'noType' ]))
|
|
||||||
.toThrow(new InternalServerError(`Unexpected type preference: noType`));
|
.toThrow(new InternalServerError(`Unexpected type preference: noType`));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('filters out internal types.', async(): Promise<void> => {
|
it('filters out internal types.', async(): Promise<void> => {
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: ValuePreferences = { '*/*': 1 };
|
||||||
{ type: { '*/*': 1 }};
|
expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
|
||||||
expect(matchingMediaTypes(preferences, [ 'a/x', 'internal/quads' ]))
|
|
||||||
.toEqual([ 'a/x' ]);
|
.toEqual([ 'a/x' ]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps internal types that are specifically requested.', async(): Promise<void> => {
|
it('keeps internal types that are specifically requested.', async(): Promise<void> => {
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: ValuePreferences = { '*/*': 1, 'internal/*': 0.5 };
|
||||||
{ type: { '*/*': 1, 'internal/*': 0.5 }};
|
expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
|
||||||
expect(matchingMediaTypes(preferences, [ 'a/x', 'internal/quads' ]))
|
|
||||||
.toEqual([ 'a/x', 'internal/quads' ]);
|
.toEqual([ 'a/x', 'internal/quads' ]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('takes the most relevant weight for a type.', async(): Promise<void> => {
|
it('takes the most relevant weight for a type.', async(): Promise<void> => {
|
||||||
const preferences: RepresentationPreferences =
|
const preferences: ValuePreferences = { '*/*': 1, 'internal/quads': 0.5 };
|
||||||
{ type: { '*/*': 1, 'internal/quads': 0.5 }};
|
expect(matchingMediaTypes(preferences, { 'a/x': 1, 'internal/quads': 1 }))
|
||||||
expect(matchingMediaTypes(preferences, [ 'a/x', 'internal/quads' ]))
|
|
||||||
.toEqual([ 'a/x', 'internal/quads' ]);
|
.toEqual([ 'a/x', 'internal/quads' ]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user