Skip to content

Commit

Permalink
fix(cucumber): resolve paths to "imports" as absolute file URLs
Browse files Browse the repository at this point in the history
use the same mechanisms for resolving paths to specs and "requires" to ensure consistency

Related tickets: #2060
  • Loading branch information
jan-molak committed Nov 14, 2023
1 parent 092b03d commit fc9aefc
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 109 deletions.
53 changes: 34 additions & 19 deletions packages/core/spec/io/Path.spec.ts
@@ -1,7 +1,7 @@
import { describe, it } from 'mocha';
import { given } from 'mocha-testdata';

import { Path } from '../../src/io';
import { Path } from '../../src/io/Path';
import { expect } from '../expect';

describe ('Path', () => {
Expand Down Expand Up @@ -44,9 +44,10 @@ describe ('Path', () => {
});

given([
{ path: Path.from('../home'), expected: '../home' },
{ path: Path.from('./home'), expected: 'home' },
{ path: Path.from('home'), expected: 'home' },
{ path: Path.from('../home'), expected: '../home' },
{ path: Path.from('./home'), expected: 'home' },
{ path: Path.from('home'), expected: 'home' },
{ path: Path.from('file:///home'), expected: '/home' },
]).
it('can be instantiated from a path segment', ({ path, expected }) => {
expect(path.value).to.equal(expected);
Expand Down Expand Up @@ -114,24 +115,38 @@ describe ('Path', () => {
});

given(
{ description: 'localhost', uri: 'file://localhost/etc/fstab', expected: '/etc/fstab' },
{ description: 'no host', uri: 'file:///etc/fstab', expected: '/etc/fstab' },
{ description: 'Windows, no host', uri: 'file:///c:/WINDOWS/clock.avi', expected: 'c:/WINDOWS/clock.avi' },
{ description: 'Windows, localhost, pipe instead of colon', uri: 'file://localhost/c|/WINDOWS/clock.avi', expected: 'c:/WINDOWS/clock.avi' },
{ description: 'Windows, no host, pipe instead of colon', uri: 'file:///c|/WINDOWS/clock.avi', expected: 'c:/WINDOWS/clock.avi' },
{ description: 'Windows, localhost', uri: 'file://localhost/c:/WINDOWS/clock.avi', expected: 'c:/WINDOWS/clock.avi' },
{ description: 'spaces in file name', uri: 'file:///hostname/path/to/the%20file.txt', expected: '/hostname/path/to/the file.txt' },
{ description: 'Windows, spaces in file name', uri: 'file:///c:/path/to/the%20file.txt', expected: 'c:/path/to/the file.txt' },
{ description: 'Windows, spaces in directory name', uri: 'file:///C:/Documents%20and%20Settings/user/FileSchemeURIs.doc', expected: 'C:/Documents and Settings/user/FileSchemeURIs.doc' },
{ description: 'Windows, special characters', uri: 'file:///C:/caf%C3%A9/%C3%A5r/d%C3%BCnn/%E7%89%9B%E9%93%83/Ph%E1%BB%9F/%F0%9F%98%B5.exe', expected: 'C:/café/år/dünn/牛铃/Phở/😵.exe' },
{ description: 'localhost', url: 'file://localhost/etc/fstab', expected: '/etc/fstab' },
{ description: 'no host', url: 'file:///etc/fstab', expected: '/etc/fstab' },
{ description: 'Windows, no host', url: 'file:///c:/WINDOWS/clock.avi', expected: 'c:/WINDOWS/clock.avi' },
{ description: 'Windows, localhost, pipe instead of colon', url: 'file://localhost/c|/WINDOWS/clock.avi', expected: 'c:/WINDOWS/clock.avi' },
{ description: 'Windows, no host, pipe instead of colon', url: 'file:///c|/WINDOWS/clock.avi', expected: 'c:/WINDOWS/clock.avi' },
{ description: 'Windows, localhost', url: 'file://localhost/c:/WINDOWS/clock.avi', expected: 'c:/WINDOWS/clock.avi' },
{ description: 'spaces in file name', url: 'file:///hostname/path/to/the%20file.txt', expected: '/hostname/path/to/the file.txt' },
{ description: 'Windows, spaces in file name', url: 'file:///c:/path/to/the%20file.txt', expected: 'c:/path/to/the file.txt' },
{ description: 'Windows, spaces in directory name', url: 'file:///C:/Documents%20and%20Settings/user/FileSchemeURIs.doc', expected: 'C:/Documents and Settings/user/FileSchemeURIs.doc' },
{ description: 'Windows, special characters', url: 'file:///C:/caf%C3%A9/%C3%A5r/d%C3%BCnn/%E7%89%9B%E9%93%83/Ph%E1%BB%9F/%F0%9F%98%B5.exe', expected: 'C:/café/år/dünn/牛铃/Phở/😵.exe' },
).
it('can be instantiated from a file:// URI', ({ uri, expected }) => {
expect(Path.fromURI(uri).value).to.equal(expected);
it('can be instantiated from a file:// URI', ({ url, expected }) => {
expect(Path.fromFileURL(new URL(url)).value).to.equal(expected);
});

given(
{ description: 'no host', path: '/etc/fstab', expected: 'file:///etc/fstab' },
{ description: 'Windows, no host', path: 'c:/WINDOWS/clock.avi', expected: 'file:///c:/WINDOWS/clock.avi' },
{ description: 'spaces in file name', path: '/hostname/path/to/the file.txt', expected: 'file:///hostname/path/to/the%20file.txt' },
{ description: 'Windows, backslashes', path: 'c:\\path\\to\\file.txt', expected: 'file:///c:/path/to/file.txt' },
{ description: 'Windows, slashes', path: 'c:/path/to/file.txt', expected: 'file:///c:/path/to/file.txt' },
{ description: 'Windows, spaces in file name', path: 'c:/path/to/the file.txt', expected: 'file:///c:/path/to/the%20file.txt' },
{ description: 'Windows, spaces in directory name', path: 'C:/Documents and Settings/user/FileSchemeURIs.doc', expected: 'file:///C:/Documents%20and%20Settings/user/FileSchemeURIs.doc' },
{ description: 'Windows, special characters', path: 'C:/café/år/dünn/牛铃/Phở/😵.exe', expected: 'file:///C:/caf%C3%A9/%C3%A5r/d%C3%BCnn/%E7%89%9B%E9%93%83/Ph%E1%BB%9F/%F0%9F%98%B5.exe' },
).
it('can be converted to a file:// URI', ({ path, expected }) => {
expect(Path.from(path).toFileURL().toString()).to.equal(expected);
})

it('complains when instantiated from URI with a non-file URI', () => {
expect(() => {
Path.fromURI('https://serenity-js.org/index.html');
}).to.throw(TypeError, `A Path can be created only from URIs that start with 'file://'. Received: https://serenity-js.org/index.html`)
Path.fromFileURL(new URL('https://serenity-js.org/index.html'));
}).to.throw(TypeError, `A Path can be created only from URLs that start with 'file://'. Received: https://serenity-js.org/index.html`)
})
});
});
27 changes: 16 additions & 11 deletions packages/core/src/io/Path.ts
Expand Up @@ -10,19 +10,16 @@ export class Path extends TinyType {
return new Path(v);
}

static fromURI(uri: string): Path {
static fromFileURL(fileUrl: URL): Path {
// inspired by https://github.com/TooTallNate/file-uri-to-path
if (
typeof uri !== 'string' ||
uri.length <= 7 ||
uri.slice(0, 7) !== 'file://'
) {
if (fileUrl.protocol !== 'file:') {
throw new TypeError(
`A Path can be created only from URIs that start with 'file://'. Received: ${ uri }`
`A Path can be created only from URLs that start with 'file://'. Received: ${ fileUrl }`
);
}

const rest = decodeURI(uri.slice(7));
const url = fileUrl.toString();
const rest = decodeURI(url.slice(7));
const firstSlash = rest.indexOf('/');

let host = rest.slice(0, Math.max(0, firstSlash));
Expand Down Expand Up @@ -51,8 +48,7 @@ export class Path extends TinyType {

// for Windows, we need to invert the path separators from what a URI uses
if (sep === '\\') {
throw new Error('that used?')
// path = path.replace(/\//g, '\\');
path = path.replaceAll('/', '\\');
}

if (! (/^.+:/.test(path))) {
Expand All @@ -64,6 +60,9 @@ export class Path extends TinyType {
}

static from(...segments: string[]): Path {
if (segments.length === 1 && segments[0].startsWith('file://')) {
return Path.fromFileURL(new URL(segments[0]));
}
return new Path(path.joinSafe(...segments));
}

Expand Down Expand Up @@ -122,4 +121,10 @@ export class Path extends TinyType {
root(): Path {
return new Path(path.parse(this.value).root);
}
}

toFileURL(): URL {
return new URL(
encodeURI(`file://${this.value}`).replaceAll(/[#?]/g, encodeURIComponent)
);
}
}
85 changes: 63 additions & 22 deletions packages/cucumber/spec/adapter/CucumberOptions.spec.ts
@@ -1,14 +1,16 @@
/* eslint-disable unicorn/no-null */
import { expect } from '@integration/testing-tools';
import type { FileSystem, Path} from '@serenity-js/core/lib/io';
import { Version } from '@serenity-js/core/lib/io';
import type { FileSystem } from '@serenity-js/core/lib/io';
import { FileFinder, Path, Version } from '@serenity-js/core/lib/io';
import { describe, it } from 'mocha';
import { given } from 'mocha-testdata';

import { CucumberOptions } from '../../src/adapter/CucumberOptions';

describe('CucumberOptions', () => {

const finder = new FileFinder(Path.from(__dirname));

describe('strict mode', () => {

given([
Expand All @@ -19,7 +21,7 @@ describe('CucumberOptions', () => {
new Version('5.0.0'),
]).
it('is strict by default', (majorVersion: Version) => {
const options = new CucumberOptions(dummyFS(), { });
const options = new CucumberOptions(finder, dummyFS(), { });

expect(options.isStrict()).to.equal(true);

Expand All @@ -34,7 +36,7 @@ describe('CucumberOptions', () => {
new Version('5.0.0'),
]).
it('can be explicitly enabled', (majorVersion: Version) => {
const options = new CucumberOptions(dummyFS(), { strict: true });
const options = new CucumberOptions(finder, dummyFS(), { strict: true });

expect(options.isStrict()).to.equal(true);

Expand All @@ -49,7 +51,7 @@ describe('CucumberOptions', () => {
new Version('5.0.0'),
]).
it('can be disabled', (majorVersion: Version) => {
const options = new CucumberOptions(dummyFS(), { strict: false });
const options = new CucumberOptions(finder, dummyFS(), { strict: false });

expect(options.isStrict()).to.equal(false);

Expand All @@ -64,7 +66,7 @@ describe('CucumberOptions', () => {
new Version('5.0.0'),
]).
it('can be disabled via cucumberOpts.noStrict', (majorVersion: Version) => {
const options = new CucumberOptions(dummyFS(), { noStrict: true } as any);
const options = new CucumberOptions(finder, dummyFS(), { noStrict: true } as any);

expect(options.isStrict()).to.equal(false);

Expand All @@ -83,18 +85,18 @@ describe('CucumberOptions', () => {
new Version('5.0.0'),
]).
it('returns no additional arguments when the config is empty', (majorVersion: Version) => {
const options = new CucumberOptions(dummyFS(), {});
const options = new CucumberOptions(finder, dummyFS(), {});

expect(options.asArgumentsForCucumber(majorVersion)).to.deep.equal(['node', 'cucumber-js']);
});

/**
* @see https://github.com/cucumber/cucumber-js/blob/main/features/rerun_formatter.feature
*/
*/
describe('rerun formatter', () => {

it('adds the rerun formatter', () => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
format: 'rerun=@rerun.txt',
});

Expand All @@ -104,7 +106,7 @@ describe('CucumberOptions', () => {
});

it('appends the rerun file', () => {
const options = new CucumberOptions(fsWithRerunFile(true), {
const options = new CucumberOptions(finder, fsWithRerunFile(true), {
format: 'rerun=@rerun.txt',
rerun: '@rerun.txt'
});
Expand All @@ -115,7 +117,7 @@ describe('CucumberOptions', () => {
});

it('does not append the rerun file when it does not exist', () => {
const options = new CucumberOptions(fsWithRerunFile(false), {
const options = new CucumberOptions(finder, fsWithRerunFile(false), {
format: 'rerun=@rerun.txt',
rerun: '@rerun.txt'
});
Expand All @@ -137,15 +139,15 @@ describe('CucumberOptions', () => {
];

given(emptyTags).it('ignores empty tags when generating tag expressions (>=2.x)', ({ tags }) => {
const options = new CucumberOptions(dummyFS(), { tags });
const options = new CucumberOptions(finder, dummyFS(), { tags });

expect(options.asArgumentsForCucumber(new Version('2.0.0'))).to.deep.equal([
'node', 'cucumber-js',
]);
});

given(emptyTags).it('ignores empty tags when working with Cucumber 1.x', ({ tags }) => {
const options = new CucumberOptions(dummyFS(), { tags });
const options = new CucumberOptions(finder, dummyFS(), { tags });

expect(options.asArgumentsForCucumber(new Version('2.0.0'))).to.deep.equal([
'node', 'cucumber-js',
Expand All @@ -157,7 +159,7 @@ describe('CucumberOptions', () => {
new Version('3.0.0'),
]).
it('converts a list of tags into a Cucumber expression for Cucumber 2.x and newer', (majorVersion: Version) => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
tags: [
'@smoke-test',
'~@wip',
Expand All @@ -171,7 +173,7 @@ describe('CucumberOptions', () => {
});

it('passes the tags individually to Cucumber 1.x', () => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
tags: [
'@smoke-test',
'~@wip',
Expand Down Expand Up @@ -202,7 +204,7 @@ describe('CucumberOptions', () => {
{ description: 'colors on', option: 'colors', state: true, expected: '--colors' },
]).
it('correctly interprets boolean options', ({ option, state, expected }) => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
[option]: state,
});

Expand All @@ -226,7 +228,7 @@ describe('CucumberOptions', () => {
{ description: 'colors on', option: 'no-colors', state: false, expected: '--colors' },
]).
it('correctly interprets negated boolean options', ({ option, state, expected }) => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
[option]: state,
});

Expand All @@ -244,7 +246,7 @@ describe('CucumberOptions', () => {
{ description: 'name', option: 'name', value: [ 'checkout.*', 'smoke.*' ], expected: [ '--name', 'checkout.*', '--name', 'smoke.*' ] },
]).
it('includes any other options', ({ option, value, expected }) => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
[option]: value,
});

Expand All @@ -263,7 +265,7 @@ describe('CucumberOptions', () => {
{ description: 'retryTagFilter', option: 'retryTagFilter', value: '@flaky', expected: [ '--retry-tag-filter', '@flaky' ] },
]).
it('converts camelCased options to kebab-case', ({ option, value, expected }) => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
[option]: value,
});

Expand All @@ -281,7 +283,7 @@ describe('CucumberOptions', () => {
{ description: 'empty list', option: 'format', value: [], },
]).
it('ignores empty values', ({ option, value }) => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
[option]: value,
});

Expand All @@ -299,7 +301,7 @@ describe('CucumberOptions', () => {
{ description: 'string', option: 'worldParameters', value: '{"baseUrl":"https://example.org"}' },
]).
it('ignores empty values', ({ option, value }) => {
const options = new CucumberOptions(dummyFS(), {
const options = new CucumberOptions(finder, dummyFS(), {
[option]: value,
});

Expand All @@ -309,6 +311,45 @@ describe('CucumberOptions', () => {
});
});
});

describe('when used to produce configuration for Cucumber API', () => {

it('resolves spec `paths` as absolute file paths', () => {
const options = new CucumberOptions(finder, dummyFS(), {
paths: [ './features/**/*.feature' ],
});

expect(options.asCucumberApiConfiguration().paths).to.deep.equal(
[
Path.from(__dirname, 'features/passing_scenario.feature').value,
]
);
});

it('resolves `import` option as absolute file URLs', () => {
const options = new CucumberOptions(finder, dummyFS(), {
import: [ './features/step_definitions/**/*.ts' ],
});

expect(options.asCucumberApiConfiguration().import).to.deep.equal(
[
Path.from(__dirname, 'features/step_definitions/steps.ts').toFileURL().href,
]
);
});

it('resolves `require` option as absolute file paths', () => {
const options = new CucumberOptions(finder, dummyFS(), {
require: [ './features/step_definitions/**/*.ts' ],
});

expect(options.asCucumberApiConfiguration().require).to.deep.equal(
[
Path.from(__dirname, 'features/step_definitions/steps.ts').value,
]
);
});
});
});

function dummyFS(): FileSystem {
Expand All @@ -321,4 +362,4 @@ function fsWithRerunFile(hasFile: boolean): FileSystem {
return hasFile
}
} as unknown as FileSystem;
}
}

0 comments on commit fc9aefc

Please sign in to comment.