Skip to content

Commit a6e0828

Browse files
Merge branch 'AIMCP-4-add-support-to-upload-prd-file' into main
2 parents 42c3cfc + f09d9e7 commit a6e0828

15 files changed

+896
-12
lines changed

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,4 @@
5757
"vite": "^6.3.5",
5858
"vitest": "^3.1.3"
5959
}
60-
}
60+
}

src/lib/inmemory-store.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const signedUrlMap = new Map<string, object>();
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
1+
import axios from "axios";
2+
import {
3+
TCG_TRIGGER_URL,
4+
TCG_POLL_URL,
5+
FETCH_DETAILS_URL,
6+
FORM_FIELDS_URL,
7+
BULK_CREATE_URL,
8+
} from "./config";
9+
import {
10+
DefaultFieldMaps,
11+
Scenario,
12+
CreateTestCasesFromFileArgs,
13+
} from "./types";
14+
import { createTestCasePayload } from "./helpers";
15+
import config from "../../../config";
16+
17+
/**
18+
* Fetch default and custom form fields for a project.
19+
*/
20+
export async function fetchFormFields(
21+
projectId: string,
22+
): Promise<{ default_fields: any; custom_fields: any }> {
23+
const res = await axios.get(FORM_FIELDS_URL(projectId), {
24+
headers: {
25+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
26+
},
27+
});
28+
return res.data;
29+
}
30+
31+
/**
32+
* Trigger AI-based test case generation for a document.
33+
*/
34+
export async function triggerTestCaseGeneration(
35+
document: string,
36+
documentId: number,
37+
folderId: string,
38+
projectId: string,
39+
source: string,
40+
): Promise<string> {
41+
const res = await axios.post(
42+
TCG_TRIGGER_URL,
43+
{
44+
document,
45+
documentId,
46+
folderId,
47+
projectId,
48+
source,
49+
webhookUrl: `https://test-management.browserstack.com/api/v1/projects/${projectId}/folder/${folderId}/webhooks/tcg`,
50+
},
51+
{
52+
headers: {
53+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
54+
"Content-Type": "application/json",
55+
"request-source": source,
56+
},
57+
},
58+
);
59+
if (res.status !== 200) {
60+
throw new Error(`Trigger failed: ${res.statusText}`);
61+
}
62+
return res.data["x-bstack-traceRequestId"];
63+
}
64+
65+
/**
66+
* Initiate a fetch for test-case details; returns the traceRequestId for polling.
67+
*/
68+
export async function fetchTestCaseDetails(
69+
documentId: number,
70+
folderId: string,
71+
projectId: string,
72+
testCaseIds: string[],
73+
source: string,
74+
): Promise<string> {
75+
if (testCaseIds.length === 0) {
76+
throw new Error("No testCaseIds provided to fetchTestCaseDetails");
77+
}
78+
const res = await axios.post(
79+
FETCH_DETAILS_URL,
80+
{
81+
document_id: documentId,
82+
folder_id: folderId,
83+
project_id: projectId,
84+
test_case_ids: testCaseIds,
85+
},
86+
{
87+
headers: {
88+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
89+
"request-source": source,
90+
"Content-Type": "application/json",
91+
},
92+
},
93+
);
94+
if (res.data.data.success !== true) {
95+
throw new Error(`Fetch details failed: ${res.data.data.message}`);
96+
}
97+
return res.data.request_trace_id;
98+
}
99+
100+
/**
101+
* Poll for a given traceRequestId until all test-case details are returned.
102+
*/
103+
export async function pollTestCaseDetails(
104+
traceRequestId: string,
105+
): Promise<Record<string, any>> {
106+
const detailMap: Record<string, any> = {};
107+
let done = false;
108+
109+
while (!done) {
110+
// add a bit of jitter to avoid synchronized polling storms
111+
await new Promise((r) => setTimeout(r, 10000 + Math.random() * 5000));
112+
113+
const poll = await axios.post(
114+
`${TCG_POLL_URL}?x-bstack-traceRequestId=${encodeURIComponent(
115+
traceRequestId,
116+
)}`,
117+
{},
118+
{
119+
headers: {
120+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
121+
},
122+
},
123+
);
124+
125+
if (!poll.data.data.success) {
126+
throw new Error(`Polling failed: ${poll.data.data.message}`);
127+
}
128+
129+
for (const msg of poll.data.data.message) {
130+
if (msg.type === "termination") {
131+
done = true;
132+
}
133+
if (msg.type === "testcase_details") {
134+
for (const test of msg.data.testcase_details) {
135+
detailMap[test.id] = {
136+
steps: test.steps,
137+
preconditions: test.preconditions,
138+
};
139+
}
140+
}
141+
}
142+
}
143+
144+
return detailMap;
145+
}
146+
147+
/**
148+
* Poll for scenarios & testcases, trigger detail fetches, then poll all details in parallel.
149+
*/
150+
export async function pollScenariosTestDetails(
151+
args: CreateTestCasesFromFileArgs,
152+
traceId: string,
153+
context: any,
154+
documentId: number,
155+
source: string,
156+
): Promise<Record<string, Scenario>> {
157+
const { folderId, projectReferenceId } = args;
158+
const scenariosMap: Record<string, Scenario> = {};
159+
const detailPromises: Promise<Record<string, any>>[] = [];
160+
let iteratorCount = 0;
161+
162+
// Promisify interval-style polling using a wrapper
163+
await new Promise<void>((resolve, reject) => {
164+
const intervalId = setInterval(async () => {
165+
try {
166+
const poll = await axios.post(
167+
`${TCG_POLL_URL}?x-bstack-traceRequestId=${encodeURIComponent(traceId)}`,
168+
{},
169+
{
170+
headers: {
171+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
172+
},
173+
},
174+
);
175+
176+
if (poll.status !== 200) {
177+
clearInterval(intervalId);
178+
reject(new Error(`Polling error: ${poll.statusText}`));
179+
return;
180+
}
181+
182+
for (const msg of poll.data.data.message) {
183+
if (msg.type === "scenario") {
184+
msg.data.scenarios.forEach((sc: any) => {
185+
scenariosMap[sc.id] = { id: sc.id, name: sc.name, testcases: [] };
186+
});
187+
const count = Object.keys(scenariosMap).length;
188+
await context.sendNotification({
189+
method: "notifications/progress",
190+
params: {
191+
progressToken: context._meta?.progressToken ?? traceId,
192+
progress: count,
193+
total: count,
194+
message: `Fetched ${count} scenarios`,
195+
},
196+
});
197+
}
198+
199+
if (msg.type === "testcase") {
200+
const sc = msg.data.scenario;
201+
if (sc) {
202+
const array = Array.isArray(msg.data.testcases)
203+
? msg.data.testcases
204+
: msg.data.testcases
205+
? [msg.data.testcases]
206+
: [];
207+
const ids = array.map((tc: any) => tc.id || tc.test_case_id);
208+
209+
const reqId = await fetchTestCaseDetails(
210+
documentId,
211+
folderId,
212+
projectReferenceId,
213+
ids,
214+
source,
215+
);
216+
detailPromises.push(pollTestCaseDetails(reqId));
217+
218+
scenariosMap[sc.id] ||= {
219+
id: sc.id,
220+
name: sc.name,
221+
testcases: [],
222+
traceId,
223+
};
224+
scenariosMap[sc.id].testcases.push(...array);
225+
iteratorCount++;
226+
const total = Object.keys(scenariosMap).length;
227+
await context.sendNotification({
228+
method: "notifications/progress",
229+
params: {
230+
progressToken: context._meta?.progressToken ?? traceId,
231+
progress: iteratorCount,
232+
total,
233+
message: `Fetched ${array.length} test cases for scenario ${iteratorCount} out of ${total}`,
234+
},
235+
});
236+
}
237+
}
238+
239+
if (msg.type === "termination") {
240+
clearInterval(intervalId);
241+
resolve();
242+
}
243+
}
244+
} catch (err) {
245+
clearInterval(intervalId);
246+
reject(err);
247+
}
248+
}, 10000); // 10 second interval
249+
});
250+
251+
// once all detail fetches are triggered, wait for them to complete
252+
const detailsList = await Promise.all(detailPromises);
253+
const allDetails = detailsList.reduce((acc, cur) => ({ ...acc, ...cur }), {});
254+
255+
// attach the fetched detail objects back to each testcase
256+
for (const scenario of Object.values(scenariosMap)) {
257+
scenario.testcases = scenario.testcases.map((tc: any) => ({
258+
...tc,
259+
...(allDetails[tc.id || tc.test_case_id] ?? {}),
260+
}));
261+
}
262+
263+
return scenariosMap;
264+
}
265+
266+
/**
267+
* Bulk-create generated test cases in BrowserStack.
268+
*/
269+
export async function bulkCreateTestCases(
270+
scenariosMap: Record<string, Scenario>,
271+
projectId: string,
272+
folderId: string,
273+
fieldMaps: DefaultFieldMaps,
274+
booleanFieldId: number | undefined,
275+
traceId: string,
276+
context: any,
277+
documentId: number,
278+
): Promise<string> {
279+
const results: Record<string, any> = {};
280+
const total = Object.keys(scenariosMap).length;
281+
let doneCount = 0;
282+
let testCaseCount = 0;
283+
284+
for (const { id, testcases } of Object.values(scenariosMap)) {
285+
const testCaseLength = testcases.length;
286+
testCaseCount += testCaseLength;
287+
if (testCaseLength === 0) continue;
288+
const payload = {
289+
test_cases: testcases.map((tc) =>
290+
createTestCasePayload(
291+
tc,
292+
id,
293+
folderId,
294+
fieldMaps,
295+
documentId,
296+
booleanFieldId,
297+
traceId,
298+
),
299+
),
300+
};
301+
302+
try {
303+
const resp = await axios.post(
304+
BULK_CREATE_URL(projectId, folderId),
305+
payload,
306+
{
307+
headers: {
308+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
309+
"Content-Type": "application/json",
310+
},
311+
},
312+
);
313+
results[id] = resp.data;
314+
await context.sendNotification({
315+
method: "notifications/progress",
316+
params: {
317+
progressToken: context._meta?.progressToken ?? "bulk-create",
318+
message: `Bulk create done for scenario ${doneCount} of ${total}`,
319+
total,
320+
progress: doneCount,
321+
},
322+
});
323+
} catch (error) {
324+
//send notification
325+
await context.sendNotification({
326+
method: "notifications/progress",
327+
params: {
328+
progressToken: context._meta?.progressToken ?? traceId,
329+
message: `Bulk create failed for scenario ${id}: ${error instanceof Error ? error.message : "Unknown error"}`,
330+
total,
331+
progress: doneCount,
332+
},
333+
});
334+
//continue to next scenario
335+
continue;
336+
}
337+
doneCount++;
338+
}
339+
const resultString = `Total of ${testCaseCount} test cases created in ${total} scenarios.`;
340+
return resultString;
341+
}
342+
343+
export async function projectIdentifierToId(
344+
projectId: string,
345+
): Promise<string> {
346+
const url = `https://test-management.browserstack.com/api/v1/projects/?q=${projectId}`;
347+
348+
const response = await axios.get(url, {
349+
headers: {
350+
"API-TOKEN": `${config.browserstackUsername}:${config.browserstackAccessKey}`,
351+
accept: "application/json, text/plain, */*",
352+
},
353+
});
354+
if (response.data.success !== true) {
355+
throw new Error(`Failed to fetch project ID: ${response.statusText}`);
356+
}
357+
for (const project of response.data.projects) {
358+
if (project.identifier === projectId) {
359+
return project.id;
360+
}
361+
}
362+
throw new Error(`Project with identifier ${projectId} not found.`);
363+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const TCG_TRIGGER_URL =
2+
"https://test-management.browserstack.com/api/v1/integration/tcg/test-generation/suggest-test-cases";
3+
export const TCG_POLL_URL =
4+
"https://test-management.browserstack.com/api/v1/integration/tcg/test-generation/test-cases-polling";
5+
export const FETCH_DETAILS_URL =
6+
"https://test-management.browserstack.com/api/v1/integration/tcg/test-generation/fetch-test-case-details";
7+
export const FORM_FIELDS_URL = (projectId: string): string =>
8+
`https://test-management.browserstack.com/api/v1/projects/${projectId}/form-fields-v2`;
9+
export const BULK_CREATE_URL = (projectId: string, folderId: string): string =>
10+
`https://test-management.browserstack.com/api/v1/projects/${projectId}/folder/${folderId}/bulk-test-cases`;

0 commit comments

Comments
 (0)