Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
103 changes: 103 additions & 0 deletions src/junit-adapter/dart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import path from 'path';
import Adapter from './adapter.js';

class DartAdapter extends Adapter {

getFilePath(t) {
if (t.title.startsWith('runDartTest[')) {
// Android: runDartTest[tests.path.to.test Test name]
const fileName = namespaceToFileName(t.title.split('[')[1].split(' ')[0]);
return fileName;
}
// iOS: RunnerUITests tests.path.to.test Test name
const parts = t.title.split(' ');
const pathToken = parts.length > 1 ? parts[1] : parts[0];
return namespaceToFileName(pathToken);
}

formatTest(t) {
if (t.title.startsWith('runDartTest[')) {
// Android: runDartTest[<path> <name>]
// First space is between the path and the test name; strip up to and including it, remove trailing ']'
const spaceIndex = t.title.indexOf(' ');
if (spaceIndex > -1) {
const pathToken = t.title.slice('runDartTest['.length, spaceIndex);
t.file = namespaceToFileName(pathToken);
t.title = t.title.slice(spaceIndex + 1).replace(/\]$/, '');
}
} else {
// iOS: <ClassName> <test.path> <test name>
// Skip both the classname token and the dot-separated test path token
const parts = t.title.split(' ');
if (parts.length > 2 && parts[1] && parts[1].includes('.')) {
t.file = namespaceToFileName(parts[1]);
t.title = parts.slice(2).join(' ');
} else {
const spaceIndex = t.title.indexOf(' ');
if (spaceIndex > -1) {
t.title = t.title.slice(spaceIndex + 1);
}
}
}

// classname: cut everything after the last dot (inclusive)
if (t.suite_title && t.suite_title.includes('.')) {
const lastDot = t.suite_title.lastIndexOf('.');
t.suite_title = t.suite_title.slice(0, lastDot);
}

if (!t.file) {
t.file = namespaceToFileName(t.suite_title || '');
}

// detect params
const paramMatches = t.title.match(/\[(.*?)\]/g);

if (paramMatches) {
const params = paramMatches.map((_match, index) => `param${index + 1}`);
if (params.length === 1) params[0] = 'param';
let paramIndex = 0;

t.title = t.title.replace(/\[(.*?)\]/g, () => {
if (params.length < 2) return `\${param}`;
const paramName = params[paramIndex] || `param${paramIndex + 1}`;
paramIndex++;
return `\${${paramName}}`;
});
const example = {};
paramMatches.forEach((match, index) => {
example[params[index]] = match.replace(/[[\]]/g, '');
});
t.example = example;
}

return t;
}
}

function namespaceToFileName(fileName) {
let testName = fileName;

// If it's already an extracted test name (contains dots but no runDartTest prefix)
if (fileName.includes('.') && !fileName.startsWith('runDartTest')) {
// Take only the first part before any spaces (the actual test path)
testName = fileName.split(' ')[0];
} else {
// Legacy handling for full runDartTest[...] format
const dartMatch = fileName.match(/runDartTest\[(.+?) /);
if (dartMatch) {
testName = dartMatch[1];
} else {
const parts = fileName.split(' ');
if (parts.length > 1) {
testName = parts[1];
}
}
}

const fileParts = testName.split('.');
fileParts[fileParts.length - 1] = fileParts[fileParts.length - 1]?.replace(/\$.*/, '');
return `${fileParts.join(path.sep)}.dart`;
}

export default DartAdapter;
4 changes: 4 additions & 0 deletions src/junit-adapter/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import PythonAdapter from './python.js';
import RubyAdapter from './ruby.js';
import CSharpAdapter from './csharp.js';
import KotlinAdapter from './kotlin.js';
import DartAdapter from './dart.js';

function AdapterFactory(lang, opts) {
if (lang === 'java') {
Expand All @@ -25,6 +26,9 @@ function AdapterFactory(lang, opts) {
if (lang === 'kotlin') {
return new KotlinAdapter(opts);
}
if (lang === 'dart') {
return new DartAdapter(opts);
}

return new Adapter(opts);
}
Expand Down
32 changes: 30 additions & 2 deletions src/utils/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,34 @@ const fetchSourceCode = (contents, opts = {}) => {
}
}
}
} else if (opts.lang === 'dart') {
// For Dart, locate the specific patrolTest/testWidgets block by title first —
// otherwise every test sharing the same main() gets the same @T comment.
const rawTitle = opts.title || '';
let dartTestTitle = '';

if (rawTitle.startsWith('runDartTest[')) {
// Android: runDartTest[<path> <test name>]
const spaceIndex = rawTitle.indexOf(' ');
if (spaceIndex > -1) {
dartTestTitle = rawTitle.slice(spaceIndex + 1).replace(/\]$/, '');
}
} else {
// iOS: <ClassName> <test.path> <test name>
const parts = rawTitle.split(' ');
if (parts.length > 2 && parts[1] && parts[1].includes('.')) {
dartTestTitle = parts.slice(2).join(' ');
}
}

let testLineIndex = -1;
if (dartTestTitle) {
testLineIndex = lines.findIndex(
l => (l.includes('patrolTest(') || l.includes('testWidgets(')) && l.includes(dartTestTitle),
);
}

lineIndex = testLineIndex !== -1 ? testLineIndex : lines.findIndex(l => l.includes('void main()'));
} else {
lineIndex = lines.findIndex(l => l.includes(title));
}
Expand All @@ -371,8 +399,8 @@ const fetchSourceCode = (contents, opts = {}) => {
for (let i = lineIndex; i < lineIndex + limit; i++) {
if (lines[i] === undefined) continue;

// Track brace depth for C# to stop after method closes
if (opts.lang === 'csharp') {
// Track brace depth for C# and Dart to stop after method/main closes
if (opts.lang === 'csharp' || opts.lang === 'dart') {
const line = lines[i];
// Count opening and closing braces
const openBraces = (line.match(/\{/g) || []).length;
Expand Down
1 change: 1 addition & 0 deletions src/xmlReader.js
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ class XmlReader {
if (file.endsWith('.js')) this.stats.language = 'js';
if (file.endsWith('.ts')) this.stats.language = 'ts';
if (file.endsWith('.cs')) this.stats.language = 'csharp';
if (file.endsWith('.dart')) this.stats.language = 'dart';
}

if (!fs.existsSync(file)) {
Expand Down
150 changes: 150 additions & 0 deletions tests/unit/dart_import_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { expect } from 'chai';
import { fetchSourceCode, fetchIdFromCode } from '../../src/utils/utils.js';
import DartAdapter from '../../src/junit-adapter/dart.js';

const sampleDartCode = `import 'package:patrol/patrol.dart';

void main() {
patrolTest('Login test', ($) async {
// @T00000001
await $.tap(find.text('Login'));
});

patrolTest('Login test: [admin]', ($) async {
// @T00000002
await $.tap(find.text('Login as admin'));
});

testWidgets('Logout test', (tester) async {
// @T00000003
await tester.tap(find.text('Logout'));
});
}`;

describe('Dart Code Import Tests', function () {
const adapter = new DartAdapter();

describe('fetchSourceCode', () => {
it('finds the Android test block by title and does not leak other tests', () => {
const result = fetchSourceCode(sampleDartCode, {
title: 'runDartTest[tests.login_test Login test]',
lang: 'dart',
});

expect(result).to.include("patrolTest('Login test'");
expect(result).to.include('@T00000001');
expect(result).to.not.include('@T00000002');
expect(result).to.not.include('@T00000003');
});

it('finds the correct parameterized test block by title', () => {
const result = fetchSourceCode(sampleDartCode, {
title: 'runDartTest[tests.login_test Login test: [admin]]',
lang: 'dart',
});

expect(result).to.include("patrolTest('Login test: [admin]'");
expect(result).to.include('@T00000002');
expect(result).to.not.include('@T00000001');
expect(result).to.not.include('@T00000003');
});

it('finds the iOS test block by title', () => {
const result = fetchSourceCode(sampleDartCode, {
title: 'RunnerUITests tests.logout_test Logout test',
lang: 'dart',
});

expect(result).to.include("testWidgets('Logout test'");
expect(result).to.include('@T00000003');
expect(result).to.not.include('@T00000001');
expect(result).to.not.include('@T00000002');
});

it('falls back to main() when the test title cannot be matched', () => {
const result = fetchSourceCode(sampleDartCode, {
title: 'runDartTest[tests.unknown_test Unknown test]',
lang: 'dart',
});

expect(result).to.include('void main()');
});
});

describe('fetchIdFromCode', () => {
it('returns the id for the matched test block only', () => {
const code = fetchSourceCode(sampleDartCode, {
title: 'runDartTest[tests.login_test Login test]',
lang: 'dart',
});

expect(fetchIdFromCode(code, { lang: 'dart' })).to.eq('00000001');
});
});

describe('DartAdapter#getFilePath', () => {
it('resolves the Android file path', () => {
const filePath = adapter.getFilePath({ title: 'runDartTest[tests.login_test Login test]' });
expect(filePath.replace(/\\/g, '/')).to.eq('tests/login_test.dart');
});

it('resolves the iOS file path', () => {
const filePath = adapter.getFilePath({ title: 'RunnerUITests tests.login_test Login test' });
expect(filePath.replace(/\\/g, '/')).to.eq('tests/login_test.dart');
});

it('does not misclassify a parameterized iOS title as Android', () => {
const filePath = adapter.getFilePath({ title: 'RunnerUITests tests.login_test Login: [admin]' });
expect(filePath.replace(/\\/g, '/')).to.eq('tests/login_test.dart');
});
});

describe('DartAdapter#formatTest', () => {
it('formats an Android test and sets the file from the title', () => {
const t = adapter.formatTest({ title: 'runDartTest[tests.path.to.test Login test]', suite_title: 'tests.path.to.test' });

expect(t.title).to.eq('Login test');
expect(t.file.replace(/\\/g, '/')).to.eq('tests/path/to/test.dart');
});

it('formats a parameterized Android test without leaving stray brackets', () => {
const t = adapter.formatTest({ title: 'runDartTest[tests.path.to.test Login: [admin]]', suite_title: 'tests.path.to.test' });

expect(t.title).to.eq('Login: ${param}');
expect(t.example).to.deep.eq({ param: 'admin' });
expect(t.file.replace(/\\/g, '/')).to.eq('tests/path/to/test.dart');
});

it('does not misclassify a parameterized iOS title as Android', () => {
const t = adapter.formatTest({
title: 'RunnerUITests tests.login_test Login: [admin]',
suite_title: 'tests.login_test',
});

expect(t.title).to.eq('Login: ${param}');
expect(t.file.replace(/\\/g, '/')).to.eq('tests/login_test.dart');
});

it('handles parameterized titles without a colon before the brackets', () => {
const t = adapter.formatTest({ title: 'runDartTest[tests.path.to.test Login test [admin]]', suite_title: 'tests.path.to.test' });

expect(t.title).to.eq('Login test ${param}');
expect(t.example).to.deep.eq({ param: 'admin' });
});

it('handles multiple params consistently between detection and replacement', () => {
const t = adapter.formatTest({
title: 'runDartTest[tests.path.to.test Login [admin] as [root]]',
suite_title: 'tests.path.to.test',
});

expect(t.title).to.eq('Login ${param1} as ${param2}');
expect(t.example).to.deep.eq({ param1: 'admin', param2: 'root' });
});

it('does not keep a dead originalTitle property', () => {
const t = adapter.formatTest({ title: 'runDartTest[tests.path.to.test Login test]', suite_title: 'tests.path.to.test' });
expect(t.originalTitle).to.be.undefined;
});
});
});