@ash-mallick/browserstack-sync 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +211 -0
- package/bin/cli.js +29 -0
- package/lib/browserstack.js +143 -0
- package/lib/config.js +88 -0
- package/lib/csv.js +70 -0
- package/lib/enrich.js +73 -0
- package/lib/index.js +80 -0
- package/lib/parser.js +50 -0
- package/lib/prompt.js +44 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# @ash-mallick/browserstack-sync
|
|
2
|
+
|
|
3
|
+
**By Ashutosh Mallick** — Sync **Playwright** and **Cypress** e2e spec files to **CSV** (one per spec, with test case IDs TC-001, TC-002, …) and optionally to **BrowserStack Test Management** (one folder per spec, test cases imported).
|
|
4
|
+
|
|
5
|
+
Use it in any project that has Playwright or Cypress e2e tests. Configure paths per project and run via **`am-browserstack-sync`** with `npx` or as a dependency.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Use in your project
|
|
10
|
+
|
|
11
|
+
### 1. Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install @ash-mallick/browserstack-sync
|
|
15
|
+
# or run without installing
|
|
16
|
+
npx @ash-mallick/browserstack-sync --csv-only
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### 2. Run
|
|
20
|
+
|
|
21
|
+
From your **project root** (where your `playwright/e2e` or Cypress specs live):
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# CSV only (no BrowserStack API calls)
|
|
25
|
+
npx am-browserstack-sync --csv-only
|
|
26
|
+
|
|
27
|
+
# Sync to BrowserStack – interactive: choose all specs or pick specific ones
|
|
28
|
+
npx am-browserstack-sync
|
|
29
|
+
|
|
30
|
+
# Sync all specs (no prompt, e.g. for CI)
|
|
31
|
+
npx am-browserstack-sync --all
|
|
32
|
+
|
|
33
|
+
# Sync only specific spec(s)
|
|
34
|
+
npx am-browserstack-sync --spec=login.spec
|
|
35
|
+
npx am-browserstack-sync --spec=login.spec,checkout.spec
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
If you installed the package, the command is `am-browserstack-sync`. With `npx` you can use either:
|
|
39
|
+
|
|
40
|
+
- `npx am-browserstack-sync` (uses the bin name)
|
|
41
|
+
- `npx @ash-mallick/browserstack-sync` (runs the package)
|
|
42
|
+
|
|
43
|
+
Add scripts in your project’s `package.json`:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"scripts": {
|
|
48
|
+
"sync:e2e": "am-browserstack-sync",
|
|
49
|
+
"sync:e2e-csv": "am-browserstack-sync --csv-only"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Then: `npm run sync:e2e-csv` or `npm run sync:e2e`.
|
|
55
|
+
|
|
56
|
+
### 3. Configure (optional)
|
|
57
|
+
|
|
58
|
+
Defaults:
|
|
59
|
+
|
|
60
|
+
- **E2E directory:** `playwright/e2e`
|
|
61
|
+
- **CSV output:** `playwright/e2e-csv`
|
|
62
|
+
|
|
63
|
+
For **Cypress**, set `e2eDir` to your specs folder (e.g. `cypress/e2e`, `cypress/integration`, or `cypress/specs`).
|
|
64
|
+
|
|
65
|
+
Override in one of these ways (first wins):
|
|
66
|
+
|
|
67
|
+
**A) Config file** in project root
|
|
68
|
+
|
|
69
|
+
Create `.am-browserstack-sync.json` or `am-browserstack-sync.config.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"e2eDir": "playwright/e2e",
|
|
74
|
+
"csvOutputDir": "playwright/e2e-csv"
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
(Legacy names `.am-e2e-sync.json`, `playwright-browserstack-sync.config.json`, etc. are still supported.)
|
|
79
|
+
|
|
80
|
+
**B) package.json**
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"amBrowserstackSync": {
|
|
85
|
+
"e2eDir": "tests/e2e",
|
|
86
|
+
"csvOutputDir": "tests/e2e-csv"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
(Legacy fields `amE2eSync`, `playwrightBrowserstackSync` are also supported.)
|
|
92
|
+
|
|
93
|
+
**C) Environment**
|
|
94
|
+
|
|
95
|
+
- `PLAYWRIGHT_BROWSERSTACK_E2E_DIR` – e2e directory (relative to cwd)
|
|
96
|
+
- `PLAYWRIGHT_BROWSERSTACK_CSV_DIR` – CSV output directory
|
|
97
|
+
|
|
98
|
+
### 4. BrowserStack sync
|
|
99
|
+
|
|
100
|
+
To push test cases to BrowserStack Test Management:
|
|
101
|
+
|
|
102
|
+
1. In your project root, create a `.env` file (do not commit it):
|
|
103
|
+
|
|
104
|
+
```env
|
|
105
|
+
BROWSERSTACK_USERNAME=your_username
|
|
106
|
+
BROWSERSTACK_ACCESS_KEY=your_access_key
|
|
107
|
+
BROWSERSTACK_PROJECT_ID=PR-XXXX
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or use a single token: `BROWSERSTACK_API_TOKEN=your_token`
|
|
111
|
+
|
|
112
|
+
2. Get credentials and project ID from [BrowserStack Test Management → API keys](https://test-management.browserstack.com/settings/api-keys). Project ID is in the project URL (e.g. `PR-1234`).
|
|
113
|
+
|
|
114
|
+
3. Run without `--csv-only`:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npx am-browserstack-sync
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The tool will:
|
|
121
|
+
|
|
122
|
+
- Create **one folder per spec file** under your project (e.g. `login.spec`, `checkout.spec`).
|
|
123
|
+
- **Create** test cases that don’t exist yet; **update** existing ones (matched by title or TC-id tag). No duplicates.
|
|
124
|
+
- When you change a test in the repo and run sync again, the corresponding test case on BrowserStack is updated.
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## What it does
|
|
129
|
+
|
|
130
|
+
1. **Analyzes** all spec files in your e2e dir:
|
|
131
|
+
- **Playwright:** `*.spec.ts`, `*.spec.js`, `*.test.ts`, `*.test.js`
|
|
132
|
+
- **Cypress:** `*.cy.ts`, `*.cy.js`
|
|
133
|
+
2. **Extracts** test titles from `test('...')` and `it('...')` (ignores `describe`).
|
|
134
|
+
3. **Enriches** each test with defaults: State **Active**, Type **Functional**, **automated**, plus inferred **tags** (framework: `playwright` or `cypress`, plus spec name and title).
|
|
135
|
+
4. **Writes one CSV per spec** with columns: `test_case_id`, `title`, `state`, `case_type`, `steps`, `expected_results`, `jira_issues`, `automation_status`, `tags`, `description`, `spec_file`.
|
|
136
|
+
5. **Optionally** syncs to BrowserStack: creates folders, creates/updates test cases with description, steps, expected results, state, type, automation status, and tags.
|
|
137
|
+
|
|
138
|
+
### CSV columns and BrowserStack fields
|
|
139
|
+
|
|
140
|
+
| Column / field | Description | Default / example |
|
|
141
|
+
|----------------|-------------|-------------------|
|
|
142
|
+
| **title** | Test case title | From spec `test('...')` / `it('...')` |
|
|
143
|
+
| **state** | Lifecycle state | `Active` (or Draft, In Review, Outdated, Rejected) |
|
|
144
|
+
| **case_type** | Type of test | `Functional` (or Smoke & Sanity, Regression, etc.) |
|
|
145
|
+
| **steps** | Test steps with expected result | "Step 1: Run Playwright/Cypress e2e test \| Expected: See spec file..." |
|
|
146
|
+
| **expected_results** | Expected outcome | Filled from step results |
|
|
147
|
+
| **jira_issues** | Linked Jira keys | Empty (comma-separated in CSV) |
|
|
148
|
+
| **automation_status** | Automated or not | `automated` |
|
|
149
|
+
| **tags** | Functionality / labels | Inferred from spec name + title plus `playwright`/`cypress`, `e2e` |
|
|
150
|
+
| **description** | Test description | "Playwright/Cypress e2e test. ID: TC-001. [title]" |
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Programmatic API
|
|
155
|
+
|
|
156
|
+
You can import the package and call `runSync` from your own scripts:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
import { runSync } from '@ash-mallick/browserstack-sync';
|
|
160
|
+
|
|
161
|
+
await runSync({
|
|
162
|
+
cwd: '/path/to/your/project',
|
|
163
|
+
csvOnly: true,
|
|
164
|
+
all: true, // skip interactive prompt, sync all specs
|
|
165
|
+
spec: ['login.spec'], // or sync only these specs
|
|
166
|
+
// optional: e2eDir, csvOutputDir, auth: { username, accessKey, projectId },
|
|
167
|
+
});
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Publishing this package
|
|
173
|
+
|
|
174
|
+
1. **Package name** is `@ash-mallick/browserstack-sync`. The CLI command is **`am-browserstack-sync`**.
|
|
175
|
+
|
|
176
|
+
2. **Set repository URL** in `package.json` (optional):
|
|
177
|
+
```json
|
|
178
|
+
"repository": { "type": "git", "url": "https://github.com/ash-mallick/browserstack-sync" }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
3. **Publish to npm:**
|
|
182
|
+
```bash
|
|
183
|
+
npm login
|
|
184
|
+
npm publish --access public
|
|
185
|
+
```
|
|
186
|
+
(Scoped packages require `--access public` for public installs.)
|
|
187
|
+
|
|
188
|
+
4. **Use it in another project:**
|
|
189
|
+
```bash
|
|
190
|
+
npm install @ash-mallick/browserstack-sync
|
|
191
|
+
npx am-browserstack-sync --csv-only
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## This repo as a demo
|
|
197
|
+
|
|
198
|
+
This repository is the package source and includes sample specs under `playwright/e2e/`. To run the sync **in this repo**:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
npm install
|
|
202
|
+
npm run sync:csv-only
|
|
203
|
+
# or with .env set:
|
|
204
|
+
npm run sync:csv
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
CSVs are written to `playwright/e2e-csv/`.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
**Author:** Ashutosh Mallick
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI for @ash-mallick/browserstack-sync (am-browserstack-sync).
|
|
4
|
+
* By Ashutosh Mallick. Run from your project root: npx am-browserstack-sync [options]
|
|
5
|
+
* Uses cwd as project root; config from .env, config file, or package.json.
|
|
6
|
+
*
|
|
7
|
+
* Options:
|
|
8
|
+
* --csv-only Generate CSVs only, do not sync to BrowserStack
|
|
9
|
+
* --all Sync all spec files (no interactive prompt)
|
|
10
|
+
* --spec=name Sync only these specs (comma-separated). e.g. --spec=login.spec,checkout.spec
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { runSync } from '../lib/index.js';
|
|
14
|
+
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
const csvOnly = argv.includes('--csv-only');
|
|
17
|
+
const all = argv.includes('--all');
|
|
18
|
+
const specArg = argv.find((a) => a.startsWith('--spec='));
|
|
19
|
+
let spec = [];
|
|
20
|
+
if (specArg) {
|
|
21
|
+
const value = specArg.slice('--spec='.length).trim();
|
|
22
|
+
if (value) spec = value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
const cwd = argv.includes('--cwd') ? argv[argv.indexOf('--cwd') + 1] : process.cwd();
|
|
25
|
+
|
|
26
|
+
runSync({ cwd, csvOnly, all, spec }).catch((err) => {
|
|
27
|
+
console.error(err);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
const BS_BASE = 'https://test-management.browserstack.com/api/v2';
|
|
2
|
+
|
|
3
|
+
function authHeader(auth) {
|
|
4
|
+
// Some Test Management setups expect a single token in API-TOKEN header
|
|
5
|
+
if (auth.apiToken) {
|
|
6
|
+
return { 'API-TOKEN': auth.apiToken };
|
|
7
|
+
}
|
|
8
|
+
// Standard Basic auth: username:accessKey (from Test Management → Settings → API Keys)
|
|
9
|
+
const user = (auth.username || '').trim();
|
|
10
|
+
const key = (auth.accessKey || '').trim();
|
|
11
|
+
const token = Buffer.from(`${user}:${key}`).toString('base64');
|
|
12
|
+
return { Authorization: `Basic ${token}` };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function bsRequest(method, url, auth, body = null) {
|
|
16
|
+
const opts = {
|
|
17
|
+
method,
|
|
18
|
+
headers: {
|
|
19
|
+
...authHeader(auth),
|
|
20
|
+
'Content-Type': 'application/json',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
if (body && (method === 'POST' || method === 'PATCH')) opts.body = JSON.stringify(body);
|
|
24
|
+
const res = await fetch(url, opts);
|
|
25
|
+
const text = await res.text();
|
|
26
|
+
let data = null;
|
|
27
|
+
try {
|
|
28
|
+
data = text ? JSON.parse(text) : null;
|
|
29
|
+
} catch (_) {
|
|
30
|
+
data = { raw: text };
|
|
31
|
+
}
|
|
32
|
+
if (!res.ok) {
|
|
33
|
+
throw new Error(`BrowserStack API ${res.status}: ${url} -> ${JSON.stringify(data)}`);
|
|
34
|
+
}
|
|
35
|
+
return data;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function ensureFolder(projectId, folderName, parentId, auth) {
|
|
39
|
+
const listUrl = `${BS_BASE}/projects/${encodeURIComponent(projectId)}/folders?p=1`;
|
|
40
|
+
const list = await bsRequest('GET', listUrl, auth);
|
|
41
|
+
const folders = list.folders || [];
|
|
42
|
+
const existing = folders.find(
|
|
43
|
+
(f) => f.name === folderName && (parentId == null ? f.parent_id == null : f.parent_id === parentId)
|
|
44
|
+
);
|
|
45
|
+
if (existing) return existing.id;
|
|
46
|
+
|
|
47
|
+
const createUrl = `${BS_BASE}/projects/${encodeURIComponent(projectId)}/folders`;
|
|
48
|
+
const create = await bsRequest('POST', createUrl, auth, {
|
|
49
|
+
folder: {
|
|
50
|
+
name: folderName,
|
|
51
|
+
description: `Tests from spec: ${folderName}`,
|
|
52
|
+
parent_id: parentId,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
return create.folder?.id ?? create.folder_id;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getTestCasesInFolder(projectId, folderId, auth) {
|
|
59
|
+
const all = [];
|
|
60
|
+
let page = 1;
|
|
61
|
+
let hasMore = true;
|
|
62
|
+
while (hasMore) {
|
|
63
|
+
const url = `${BS_BASE}/projects/${encodeURIComponent(projectId)}/test-cases?folder_id=${folderId}&p=${page}`;
|
|
64
|
+
const res = await bsRequest('GET', url, auth);
|
|
65
|
+
const list = res.test_cases || [];
|
|
66
|
+
all.push(...list);
|
|
67
|
+
const info = res.info || {};
|
|
68
|
+
hasMore = info.next != null;
|
|
69
|
+
page += 1;
|
|
70
|
+
}
|
|
71
|
+
return all;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildTestCasePayload(testCase) {
|
|
75
|
+
const steps = Array.isArray(testCase.steps) && testCase.steps.length > 0
|
|
76
|
+
? testCase.steps.map((s) => ({ step: s.step ?? '', result: s.result ?? '' }))
|
|
77
|
+
: [{ step: 'Run Playwright e2e test', result: 'See spec file for implementation.' }];
|
|
78
|
+
const tags = Array.isArray(testCase.tags) ? testCase.tags : ['playwright', 'e2e', testCase.id];
|
|
79
|
+
const payload = {
|
|
80
|
+
name: testCase.title,
|
|
81
|
+
description: testCase.description ? `<p>${String(testCase.description).replace(/\n/g, '</p><p>')}</p>` : `<p>Playwright e2e test. ID: ${testCase.id}. ${testCase.title}</p>`,
|
|
82
|
+
preconditions: '',
|
|
83
|
+
test_case_steps: steps,
|
|
84
|
+
tags: [...tags, testCase.id],
|
|
85
|
+
status: testCase.state ?? 'Active',
|
|
86
|
+
case_type: testCase.case_type ?? 'Functional',
|
|
87
|
+
automation_status: testCase.automation_status ?? 'automated',
|
|
88
|
+
};
|
|
89
|
+
if (Array.isArray(testCase.jira_issues) && testCase.jira_issues.length > 0) {
|
|
90
|
+
payload.issues = testCase.jira_issues;
|
|
91
|
+
}
|
|
92
|
+
return payload;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function createTestCase(projectId, folderId, testCase, auth) {
|
|
96
|
+
const url = `${BS_BASE}/projects/${encodeURIComponent(projectId)}/folders/${folderId}/test-cases`;
|
|
97
|
+
const body = { test_case: buildTestCasePayload(testCase) };
|
|
98
|
+
const res = await bsRequest('POST', url, auth, body);
|
|
99
|
+
return res.data?.test_case?.identifier ?? res.test_case?.identifier;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function testCasePayload(testCase) {
|
|
103
|
+
return buildTestCasePayload(testCase);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function updateTestCase(projectId, testCaseId, testCase, auth) {
|
|
107
|
+
const url = `${BS_BASE}/projects/${encodeURIComponent(projectId)}/test-cases/${testCaseId}`;
|
|
108
|
+
const body = { test_case: testCasePayload(testCase) };
|
|
109
|
+
await bsRequest('PATCH', url, auth, body);
|
|
110
|
+
return testCaseId;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Match existing BS test case to our local case: by title or by our TC-xxx tag */
|
|
114
|
+
function findExisting(existingList, localCase) {
|
|
115
|
+
const byTitle = existingList.find((t) => (t.title || '').trim() === (localCase.title || '').trim());
|
|
116
|
+
if (byTitle) return byTitle;
|
|
117
|
+
const byTag = existingList.find((t) => (t.tags || []).includes(localCase.id));
|
|
118
|
+
return byTag;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function syncToBrowserStack(specsMap, projectId, auth) {
|
|
122
|
+
console.log('\nSyncing to BrowserStack project', projectId);
|
|
123
|
+
for (const [baseName, { specFile, cases }] of specsMap) {
|
|
124
|
+
const folderName = baseName;
|
|
125
|
+
const folderId = await ensureFolder(projectId, folderName, null, auth);
|
|
126
|
+
const existing = await getTestCasesInFolder(projectId, folderId, auth);
|
|
127
|
+
console.log('Folder:', folderName, '(id:', folderId, ')');
|
|
128
|
+
for (const tc of cases) {
|
|
129
|
+
try {
|
|
130
|
+
const found = findExisting(existing, tc);
|
|
131
|
+
if (found) {
|
|
132
|
+
await updateTestCase(projectId, found.identifier, tc, auth);
|
|
133
|
+
console.log(' Updated', tc.id, tc.title, '->', found.identifier);
|
|
134
|
+
} else {
|
|
135
|
+
const id = await createTestCase(projectId, folderId, tc, auth);
|
|
136
|
+
console.log(' Created', tc.id, tc.title, '->', id);
|
|
137
|
+
}
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(' Failed', tc.id, tc.title, e.message);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILES = [
|
|
5
|
+
'.am-browserstack-sync.json',
|
|
6
|
+
'am-browserstack-sync.config.json',
|
|
7
|
+
'.am-e2e-sync.json',
|
|
8
|
+
'am-e2e-sync.config.json',
|
|
9
|
+
'.playwright-browserstack-sync.json',
|
|
10
|
+
'playwright-browserstack-sync.config.json',
|
|
11
|
+
'.config/am-browserstack-sync.json',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const DEFAULTS = {
|
|
15
|
+
e2eDir: 'playwright/e2e',
|
|
16
|
+
csvOutputDir: 'playwright/e2e-csv',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load config from cwd: env > config file > package.json field > defaults.
|
|
21
|
+
* Returns { cwd, e2eDir, csvOutputDir } (paths are absolute).
|
|
22
|
+
*/
|
|
23
|
+
export function loadConfig(cwd) {
|
|
24
|
+
const resolvedCwd = path.resolve(cwd || process.cwd());
|
|
25
|
+
let e2eDir = DEFAULTS.e2eDir;
|
|
26
|
+
let csvOutputDir = DEFAULTS.csvOutputDir;
|
|
27
|
+
|
|
28
|
+
// package.json field
|
|
29
|
+
const pkgPath = path.join(resolvedCwd, 'package.json');
|
|
30
|
+
if (fs.existsSync(pkgPath)) {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
|
|
33
|
+
const field = pkg.amBrowserstackSync || pkg.amE2eSync || pkg.playwrightBrowserstackSync || pkg['playwright-browserstack-sync'];
|
|
34
|
+
if (field) {
|
|
35
|
+
if (field.e2eDir) e2eDir = field.e2eDir;
|
|
36
|
+
if (field.csvOutputDir) csvOutputDir = field.csvOutputDir;
|
|
37
|
+
}
|
|
38
|
+
} catch (_) {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Config file (overrides package.json)
|
|
42
|
+
for (const name of CONFIG_FILES) {
|
|
43
|
+
const configPath = path.join(resolvedCwd, name);
|
|
44
|
+
if (fs.existsSync(configPath)) {
|
|
45
|
+
try {
|
|
46
|
+
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
47
|
+
if (data.e2eDir) e2eDir = data.e2eDir;
|
|
48
|
+
if (data.csvOutputDir) csvOutputDir = data.csvOutputDir;
|
|
49
|
+
break;
|
|
50
|
+
} catch (_) {}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Env overrides
|
|
55
|
+
if (process.env.PLAYWRIGHT_BROWSERSTACK_E2E_DIR) e2eDir = process.env.PLAYWRIGHT_BROWSERSTACK_E2E_DIR;
|
|
56
|
+
if (process.env.PLAYWRIGHT_BROWSERSTACK_CSV_DIR) csvOutputDir = process.env.PLAYWRIGHT_BROWSERSTACK_CSV_DIR;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
cwd: resolvedCwd,
|
|
60
|
+
e2eDir: path.resolve(resolvedCwd, e2eDir),
|
|
61
|
+
csvOutputDir: path.resolve(resolvedCwd, csvOutputDir),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function trim(s) {
|
|
66
|
+
return typeof s === 'string' ? s.trim() : s;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get BrowserStack credentials from env (after dotenv has been loaded in cwd).
|
|
71
|
+
* Supports either username+accessKey or a single API token (from Test Management → API Keys).
|
|
72
|
+
* Values are trimmed to avoid 401 from stray spaces/newlines in .env.
|
|
73
|
+
*/
|
|
74
|
+
export function getBrowserStackEnv() {
|
|
75
|
+
const username = trim(process.env.BROWSERSTACK_USERNAME);
|
|
76
|
+
const accessKey = trim(process.env.BROWSERSTACK_ACCESS_KEY);
|
|
77
|
+
const apiToken = trim(
|
|
78
|
+
process.env.BROWSERSTACK_API_TOKEN ||
|
|
79
|
+
process.env.TEST_MANAGEMENT_API_TOKEN
|
|
80
|
+
);
|
|
81
|
+
const projectId = trim(process.env.BROWSERSTACK_PROJECT_ID);
|
|
82
|
+
return {
|
|
83
|
+
username: username || undefined,
|
|
84
|
+
accessKey: accessKey || undefined,
|
|
85
|
+
apiToken: apiToken || undefined,
|
|
86
|
+
projectId: projectId || undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
package/lib/csv.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
function escapeCsv(s) {
|
|
5
|
+
const t = String(s ?? '');
|
|
6
|
+
if (/[",\n\r]/.test(t)) return `"${t.replace(/"/g, '""')}"`;
|
|
7
|
+
return t;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Format steps for CSV: "Step 1: ... | Expected: ... ; Step 2: ..." */
|
|
11
|
+
function formatStepsForCsv(steps) {
|
|
12
|
+
if (!Array.isArray(steps) || steps.length === 0) return '';
|
|
13
|
+
return steps
|
|
14
|
+
.map((s, i) => {
|
|
15
|
+
const step = s.step ?? s;
|
|
16
|
+
const result = s.result ?? '';
|
|
17
|
+
return `Step ${i + 1}: ${step} | Expected: ${result}`;
|
|
18
|
+
})
|
|
19
|
+
.join(' ; ');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Write one CSV per spec file under csvOutputDir.
|
|
24
|
+
* Columns: test_case_id, title, state, case_type, steps, expected_results, jira_issues, automation_status, tags, description, spec_file
|
|
25
|
+
*/
|
|
26
|
+
export function writeCsvFiles(specsMap, csvOutputDir) {
|
|
27
|
+
if (!fs.existsSync(csvOutputDir)) {
|
|
28
|
+
fs.mkdirSync(csvOutputDir, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
const headers = [
|
|
31
|
+
'test_case_id',
|
|
32
|
+
'title',
|
|
33
|
+
'state',
|
|
34
|
+
'case_type',
|
|
35
|
+
'steps',
|
|
36
|
+
'expected_results',
|
|
37
|
+
'jira_issues',
|
|
38
|
+
'automation_status',
|
|
39
|
+
'tags',
|
|
40
|
+
'description',
|
|
41
|
+
'spec_file',
|
|
42
|
+
];
|
|
43
|
+
for (const [baseName, { specFile, cases }] of specsMap) {
|
|
44
|
+
const rows = [
|
|
45
|
+
headers.map(escapeCsv).join(','),
|
|
46
|
+
...cases.map((c) => {
|
|
47
|
+
const stepsStr = formatStepsForCsv(c.steps);
|
|
48
|
+
const jiraStr = Array.isArray(c.jira_issues) ? c.jira_issues.join(', ') : '';
|
|
49
|
+
const tagsStr = Array.isArray(c.tags) ? c.tags.join(', ') : '';
|
|
50
|
+
const expectedResults = c.expected_results ?? (c.steps && c.steps.length ? c.steps.map((s) => s.result).join(' ') : '');
|
|
51
|
+
return [
|
|
52
|
+
c.id,
|
|
53
|
+
c.title,
|
|
54
|
+
c.state ?? 'Active',
|
|
55
|
+
c.case_type ?? 'Functional',
|
|
56
|
+
stepsStr,
|
|
57
|
+
expectedResults,
|
|
58
|
+
jiraStr,
|
|
59
|
+
c.automation_status ?? 'automated',
|
|
60
|
+
tagsStr,
|
|
61
|
+
c.description ?? '',
|
|
62
|
+
specFile,
|
|
63
|
+
].map(escapeCsv).join(',');
|
|
64
|
+
}),
|
|
65
|
+
];
|
|
66
|
+
const csvPath = path.join(csvOutputDir, `${baseName}.csv`);
|
|
67
|
+
fs.writeFileSync(csvPath, rows.join('\n'), 'utf-8');
|
|
68
|
+
console.log('Wrote', csvPath, `(${cases.length} test cases)`);
|
|
69
|
+
}
|
|
70
|
+
}
|
package/lib/enrich.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enrich test cases with state, type, steps, tags, description, etc.
|
|
3
|
+
* Supports Playwright and Cypress; infer tags from spec name + title.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DEFAULT_STATE = 'Active';
|
|
7
|
+
const DEFAULT_CASE_TYPE = 'Functional';
|
|
8
|
+
const DEFAULT_AUTOMATION_STATUS = 'automated';
|
|
9
|
+
const DEFAULT_EXPECTED_RESULT = 'See spec file for implementation and assertions.';
|
|
10
|
+
|
|
11
|
+
const FRAMEWORK = {
|
|
12
|
+
playwright: { step: 'Run Playwright e2e test', desc: 'Playwright e2e test' },
|
|
13
|
+
cypress: { step: 'Run Cypress e2e test', desc: 'Cypress e2e test' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function slug(text) {
|
|
17
|
+
return String(text || '')
|
|
18
|
+
.replace(/\s+/g, '_')
|
|
19
|
+
.replace(/[^a-zA-Z0-9_]/g, '')
|
|
20
|
+
.replace(/_+/g, '_')
|
|
21
|
+
.replace(/^_|_$/g, '')
|
|
22
|
+
.toLowerCase() || 'test';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Infer functionality tags from spec base name and test title; include framework tag. */
|
|
26
|
+
function inferTags(specBaseName, title, isCypress) {
|
|
27
|
+
const base = specBaseName.replace(/\.(spec|test|cy)$/i, '');
|
|
28
|
+
const specSlug = slug(base);
|
|
29
|
+
const titleSlug = slug(title.replace(/^\s*TC-\d+\s*[-–]\s*/i, ''));
|
|
30
|
+
const tags = [isCypress ? 'cypress' : 'playwright', 'e2e', specSlug];
|
|
31
|
+
if (titleSlug && titleSlug !== specSlug) tags.push(titleSlug);
|
|
32
|
+
return tags;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Enrich a single case with state, case_type, steps, expected_results, jira_issues, automation_status, tags, description.
|
|
37
|
+
*/
|
|
38
|
+
export function enrichCase(case_, specBaseName, isCypress = false) {
|
|
39
|
+
const title = case_.title || '';
|
|
40
|
+
const id = case_.id || '';
|
|
41
|
+
const fw = isCypress ? FRAMEWORK.cypress : FRAMEWORK.playwright;
|
|
42
|
+
const tags = inferTags(specBaseName, title, isCypress);
|
|
43
|
+
const steps = case_.steps && case_.steps.length > 0
|
|
44
|
+
? case_.steps
|
|
45
|
+
: [{ step: fw.step, result: DEFAULT_EXPECTED_RESULT }];
|
|
46
|
+
const expectedResults = case_.expected_results ?? steps.map((s) => s.result).join(' ');
|
|
47
|
+
const description = case_.description ?? `${fw.desc}. ID: ${id}. ${title}`;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...case_,
|
|
51
|
+
title,
|
|
52
|
+
id,
|
|
53
|
+
state: case_.state ?? DEFAULT_STATE,
|
|
54
|
+
case_type: case_.case_type ?? DEFAULT_CASE_TYPE,
|
|
55
|
+
steps,
|
|
56
|
+
expected_results: case_.expected_results ?? expectedResults,
|
|
57
|
+
jira_issues: case_.jira_issues ?? [],
|
|
58
|
+
automation_status: case_.automation_status ?? DEFAULT_AUTOMATION_STATUS,
|
|
59
|
+
tags: case_.tags && case_.tags.length > 0 ? case_.tags : tags,
|
|
60
|
+
description,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Enrich all cases in specsMap. Mutates cases in place; returns specsMap.
|
|
66
|
+
*/
|
|
67
|
+
export function enrichSpecsMap(specsMap) {
|
|
68
|
+
for (const [baseName, data] of specsMap) {
|
|
69
|
+
const isCypress = data.isCypress === true;
|
|
70
|
+
data.cases = data.cases.map((c) => enrichCase(c, baseName, isCypress));
|
|
71
|
+
}
|
|
72
|
+
return specsMap;
|
|
73
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { analyzeSpecs } from './parser.js';
|
|
4
|
+
import { enrichSpecsMap } from './enrich.js';
|
|
5
|
+
import { writeCsvFiles } from './csv.js';
|
|
6
|
+
import { syncToBrowserStack } from './browserstack.js';
|
|
7
|
+
import { loadConfig, getBrowserStackEnv } from './config.js';
|
|
8
|
+
import { promptSpecSelection } from './prompt.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run sync: analyze specs, write CSVs, optionally sync to BrowserStack.
|
|
12
|
+
* @param {Object} options
|
|
13
|
+
* @param {string} [options.cwd] - Project root (default: process.cwd())
|
|
14
|
+
* @param {string} [options.e2eDir] - Override e2e dir (absolute or relative to cwd)
|
|
15
|
+
* @param {string} [options.csvOutputDir] - Override CSV output dir
|
|
16
|
+
* @param {boolean} [options.csvOnly] - If true, skip BrowserStack sync
|
|
17
|
+
* @param {boolean} [options.all] - If true, sync all specs (skip interactive prompt)
|
|
18
|
+
* @param {string[]} [options.spec] - Sync only these spec base names (e.g. ['login.spec']). Skips prompt.
|
|
19
|
+
* @param {boolean} [options.interactive] - If false, skip prompt and sync all (default: true when no --all/--spec)
|
|
20
|
+
* @param {Object} [options.auth] - { username, accessKey, projectId } (default: from env)
|
|
21
|
+
*/
|
|
22
|
+
export async function runSync(options = {}) {
|
|
23
|
+
const config = loadConfig(options.cwd);
|
|
24
|
+
const cwd = config.cwd;
|
|
25
|
+
|
|
26
|
+
dotenv.config({ path: path.join(cwd, '.env') });
|
|
27
|
+
|
|
28
|
+
const e2eDir = options.e2eDir != null ? path.resolve(cwd, options.e2eDir) : config.e2eDir;
|
|
29
|
+
const csvOutputDir = options.csvOutputDir != null ? path.resolve(cwd, options.csvOutputDir) : config.csvOutputDir;
|
|
30
|
+
const csvOnly = options.csvOnly === true;
|
|
31
|
+
|
|
32
|
+
let specsMap = analyzeSpecs(e2eDir);
|
|
33
|
+
if (specsMap.size === 0) {
|
|
34
|
+
console.log('No spec files found in', e2eDir);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
enrichSpecsMap(specsMap);
|
|
38
|
+
|
|
39
|
+
if (!csvOnly) {
|
|
40
|
+
if (options.all) {
|
|
41
|
+
// sync all, no prompt
|
|
42
|
+
} else if (options.spec && options.spec.length > 0) {
|
|
43
|
+
const set = new Set(options.spec);
|
|
44
|
+
specsMap = new Map([...specsMap].filter(([name]) => set.has(name)));
|
|
45
|
+
if (specsMap.size === 0) {
|
|
46
|
+
console.log('No matching spec files for:', options.spec.join(', '));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
} else if (options.interactive !== false && process.stdin.isTTY) {
|
|
50
|
+
const selected = await promptSpecSelection(specsMap);
|
|
51
|
+
if (selected === null) {
|
|
52
|
+
// user chose "all"
|
|
53
|
+
} else if (selected.size === 0) {
|
|
54
|
+
console.log('No spec files selected. Exiting.');
|
|
55
|
+
return;
|
|
56
|
+
} else {
|
|
57
|
+
specsMap = new Map([...specsMap].filter(([name]) => selected.has(name)));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
writeCsvFiles(specsMap, csvOutputDir);
|
|
63
|
+
|
|
64
|
+
if (csvOnly) {
|
|
65
|
+
console.log('\nCSV-only mode. Skipping BrowserStack sync.');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const auth = options.auth || getBrowserStackEnv();
|
|
70
|
+
const hasAuth = (auth.username && auth.accessKey) || auth.apiToken;
|
|
71
|
+
if (!hasAuth || !auth.projectId) {
|
|
72
|
+
console.log(
|
|
73
|
+
'\nSkipping BrowserStack sync. Set credentials in .env: either BROWSERSTACK_USERNAME + BROWSERSTACK_ACCESS_KEY, or BROWSERSTACK_API_TOKEN (from Test Management → Settings → API Keys), and BROWSERSTACK_PROJECT_ID.'
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await syncToBrowserStack(specsMap, auth.projectId, auth);
|
|
79
|
+
console.log('\nDone.');
|
|
80
|
+
}
|
package/lib/parser.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract test titles from a Playwright or Cypress spec file.
|
|
6
|
+
* Matches test('title', ...) and it('title', ...); skips test.describe(...) / describe(...).
|
|
7
|
+
*/
|
|
8
|
+
export function extractTestTitles(content) {
|
|
9
|
+
const titles = [];
|
|
10
|
+
const regex = /\b(?:test|it)\s*(?:\.\s*(?!describe)(?:only|skip))?\s*\(\s*['"]([^'"]*)['"]/g;
|
|
11
|
+
let m;
|
|
12
|
+
while ((m = regex.exec(content)) !== null) {
|
|
13
|
+
const title = (m[1] ?? '').trim();
|
|
14
|
+
if (title) titles.push(title);
|
|
15
|
+
}
|
|
16
|
+
const backtickRegex = /\b(?:test|it)\s*(?:\.\s*(?!describe)(?:only|skip))?\s*\(\s*`([^`]*)`/g;
|
|
17
|
+
while ((m = backtickRegex.exec(content)) !== null) {
|
|
18
|
+
const title = (m[1] ?? '').trim();
|
|
19
|
+
if (title && !titles.includes(title)) titles.push(title);
|
|
20
|
+
}
|
|
21
|
+
return titles;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Match Playwright (*.spec, *.test) and Cypress (*.cy) spec files */
|
|
25
|
+
const SPEC_FILE_PATTERN = /\.(spec|test|cy)\.(ts|js)$/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* For each spec file in e2eDir, extract tests and assign TC-001, TC-002, ...
|
|
29
|
+
* Supports Playwright (*.spec.ts/js, *.test.ts/js) and Cypress (*.cy.ts/js).
|
|
30
|
+
* Returns Map<specBasename, { specFile, cases: Array<{ id, title }> }>
|
|
31
|
+
*/
|
|
32
|
+
export function analyzeSpecs(e2eDir) {
|
|
33
|
+
if (!fs.existsSync(e2eDir)) {
|
|
34
|
+
throw new Error(`E2E directory not found: ${e2eDir}`);
|
|
35
|
+
}
|
|
36
|
+
const files = fs.readdirSync(e2eDir).filter((f) => SPEC_FILE_PATTERN.test(f));
|
|
37
|
+
const results = new Map();
|
|
38
|
+
for (const file of files) {
|
|
39
|
+
const filePath = path.join(e2eDir, file);
|
|
40
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
41
|
+
const titles = extractTestTitles(content);
|
|
42
|
+
const baseName = path.basename(file, path.extname(file));
|
|
43
|
+
const cases = titles.map((title, index) => ({
|
|
44
|
+
id: `TC-${String(index + 1).padStart(3, '0')}`,
|
|
45
|
+
title: title.replace(/^\s*TC-\d+\s*[-–]\s*/i, '').trim() || title,
|
|
46
|
+
}));
|
|
47
|
+
results.set(baseName, { specFile: file, cases, isCypress: /\.cy\.(ts|js)$/i.test(file) });
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
}
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
|
|
3
|
+
function question(rl, prompt) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
rl.question(prompt, (answer) => resolve((answer || '').trim()));
|
|
6
|
+
});
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Interactive spec selection. Returns a Set of selected spec base names (e.g. 'login.spec').
|
|
11
|
+
* If user chooses "all", returns null (meaning no filter). If "choose", returns Set of chosen names.
|
|
12
|
+
*/
|
|
13
|
+
export async function promptSpecSelection(specsMap) {
|
|
14
|
+
const names = Array.from(specsMap.keys());
|
|
15
|
+
if (names.length === 0) return new Set();
|
|
16
|
+
if (names.length === 1) return null;
|
|
17
|
+
|
|
18
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
19
|
+
|
|
20
|
+
console.log('\nWhich spec file(s) do you want to sync to BrowserStack?');
|
|
21
|
+
console.log(' 1) All spec files');
|
|
22
|
+
console.log(' 2) Choose spec file(s)\n');
|
|
23
|
+
names.forEach((name, i) => {
|
|
24
|
+
console.log(` ${i + 3}) ${name}`);
|
|
25
|
+
});
|
|
26
|
+
console.log('\n (For option 2, enter the numbers of specs you want, e.g. 3,4)\n');
|
|
27
|
+
|
|
28
|
+
const choice = await question(rl, 'Enter option number(s): ');
|
|
29
|
+
rl.close();
|
|
30
|
+
|
|
31
|
+
const parts = choice.split(/[\s,]+/).filter(Boolean);
|
|
32
|
+
if (parts.includes('1')) return null;
|
|
33
|
+
|
|
34
|
+
const selected = new Set();
|
|
35
|
+
for (const p of parts) {
|
|
36
|
+
const num = parseInt(p, 10);
|
|
37
|
+
if (num >= 3 && num <= names.length + 2) {
|
|
38
|
+
selected.add(names[num - 3]);
|
|
39
|
+
} else if (names.includes(p)) {
|
|
40
|
+
selected.add(p);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return selected.size ? selected : null;
|
|
44
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ash-mallick/browserstack-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Sync Playwright & Cypress e2e specs to CSV and BrowserStack Test Management (one folder per spec)",
|
|
5
|
+
"author": "Ashutosh Mallick",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "lib/index.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"am-browserstack-sync": "bin/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"bin",
|
|
13
|
+
"lib"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "playwright test",
|
|
17
|
+
"test:e2e": "playwright test",
|
|
18
|
+
"sync": "node bin/cli.js",
|
|
19
|
+
"sync:csv": "node bin/cli.js",
|
|
20
|
+
"sync:csv-only": "node bin/cli.js --csv-only",
|
|
21
|
+
"prepublishOnly": "node bin/cli.js --csv-only",
|
|
22
|
+
"publish:public": "npm publish --access public"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"playwright",
|
|
26
|
+
"cypress",
|
|
27
|
+
"browserstack",
|
|
28
|
+
"e2e",
|
|
29
|
+
"test-management",
|
|
30
|
+
"sync",
|
|
31
|
+
"ash-mallick"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": ""
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"dotenv": "^16.4.5"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@playwright/test": "^1.49.0",
|
|
43
|
+
"playwright": "^1.49.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {},
|
|
46
|
+
"peerDependenciesMeta": {},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=18"
|
|
49
|
+
}
|
|
50
|
+
}
|