@veolab/discoverylab 1.3.2 → 1.3.4

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.
@@ -4,20 +4,9 @@ import {
4
4
  createTextResult
5
5
  } from "./chunk-XKX6NBHF.js";
6
6
  import {
7
- createLoginFlow,
8
- createNavigationTestFlow,
9
- createOnboardingFlow,
10
- generateMaestroFlow,
11
- getAdbCommand,
12
7
  getAvailableTemplates,
13
- getMaestroVersion,
14
- isMaestroInstalled,
15
- isTemplatesInstalled,
16
- listMaestroDevices,
17
- runMaestroTest,
18
- runMaestroWithCapture,
19
- startMaestroStudio
20
- } from "./chunk-VEIZLLCI.js";
8
+ isTemplatesInstalled
9
+ } from "./chunk-HGWEHWKJ.js";
21
10
  import {
22
11
  createFormSubmissionScript,
23
12
  createLoginScript,
@@ -35,12 +24,18 @@ import {
35
24
  buildAppLabNetworkProfile
36
25
  } from "./chunk-LB3RNE3O.js";
37
26
  import {
27
+ LOCAL_ESVP_SERVER_URL,
38
28
  attachESVPNetworkTrace,
39
29
  captureESVPCheckpoint,
40
30
  clearESVPNetwork,
41
31
  configureESVPNetwork,
42
32
  createESVPSession,
33
+ createLoginFlow,
34
+ createNavigationTestFlow,
35
+ createOnboardingFlow,
43
36
  finishESVPSession,
37
+ generateMaestroFlow,
38
+ getAdbCommand,
44
39
  getESVPArtifactContent,
45
40
  getESVPConnection,
46
41
  getESVPHealth,
@@ -48,15 +43,21 @@ import {
48
43
  getESVPSession,
49
44
  getESVPSessionNetwork,
50
45
  getESVPTranscript,
46
+ getMaestroVersion,
51
47
  inspectESVPSession,
48
+ isMaestroInstalled,
52
49
  listESVPArtifacts,
53
50
  listESVPDevices,
54
51
  listESVPSessions,
52
+ listMaestroDevices,
55
53
  replayESVPSession,
56
54
  runESVPActions,
57
55
  runESVPPreflight,
56
+ runMaestroTest,
57
+ runMaestroWithCapture,
58
+ startMaestroStudio,
58
59
  validateESVPReplay
59
- } from "./chunk-GAKEFJ5T.js";
60
+ } from "./chunk-7EDIUVIO.js";
60
61
  import {
61
62
  DATA_DIR,
62
63
  EXPORTS_DIR,
@@ -6452,9 +6453,14 @@ function resolveProjectRecordingReplaySessionId(session) {
6452
6453
  }
6453
6454
  function resolveProjectRecordingESVPServerUrl(session) {
6454
6455
  const esvp = resolveProjectRecordingESVPState(session);
6455
- if (!esvp || typeof esvp.serverUrl !== "string") return void 0;
6456
- const serverUrl = esvp.serverUrl.trim();
6457
- return serverUrl || void 0;
6456
+ const serverUrl = typeof esvp?.serverUrl === "string" ? esvp.serverUrl.trim().replace(/\/+$/, "") : "";
6457
+ if (serverUrl === LOCAL_ESVP_SERVER_URL) {
6458
+ return LOCAL_ESVP_SERVER_URL;
6459
+ }
6460
+ if (!serverUrl && esvp?.connectionMode === "local") {
6461
+ return LOCAL_ESVP_SERVER_URL;
6462
+ }
6463
+ return void 0;
6458
6464
  }
6459
6465
  function resolveAppLabBaseUrl(appLabUrl) {
6460
6466
  const raw = String(appLabUrl || process.env.DISCOVERYLAB_APP_URL || "http://127.0.0.1:3847").trim();
@@ -1,25 +1,9 @@
1
1
  import {
2
- findAndroidSdkPath,
3
- getAdbCommand,
4
2
  getAvailableTemplates,
5
3
  getBundlePath,
6
- getEmulatorPath,
7
- getMaestroRecorder,
8
4
  getTemplate,
9
- isIdbInstalled,
10
- isMaestroInstalled,
11
- isTemplatesInstalled,
12
- killZombieMaestroProcesses,
13
- listConnectedAndroidDevices,
14
- listMaestroDevices,
15
- parseMaestroActionsFromYaml,
16
- resolveAndroidDeviceSerial,
17
- runMaestroTest,
18
- tapViaIdb
19
- } from "./chunk-VEIZLLCI.js";
20
- import {
21
- APP_VERSION
22
- } from "./chunk-6EGBXRDK.js";
5
+ isTemplatesInstalled
6
+ } from "./chunk-HGWEHWKJ.js";
23
7
  import {
24
8
  runPlaywrightTest
25
9
  } from "./chunk-FIL7IWEL.js";
@@ -37,20 +21,37 @@ import {
37
21
  resolveLocalAppHttpTraceCollectorById,
38
22
  startLocalAppHttpTraceCollector,
39
23
  validateMaestroRecordingWithESVP
40
- } from "./chunk-N6JJ2RGV.js";
24
+ } from "./chunk-ZLHIHMSL.js";
41
25
  import {
42
26
  buildAppLabNetworkProfile
43
27
  } from "./chunk-LB3RNE3O.js";
44
28
  import {
29
+ LOCAL_ESVP_SERVER_URL,
45
30
  attachESVPNetworkTrace,
46
31
  configureESVPNetwork,
47
32
  createESVPSession,
33
+ findAndroidSdkPath,
34
+ getAdbCommand,
48
35
  getESVPReplayConsistency,
36
+ getEmulatorPath,
37
+ getMaestroRecorder,
49
38
  inspectESVPSession,
39
+ isIdbInstalled,
40
+ isMaestroInstalled,
41
+ killZombieMaestroProcesses,
42
+ listConnectedAndroidDevices,
43
+ listMaestroDevices,
44
+ parseMaestroActionsFromYaml,
50
45
  replayESVPSession,
46
+ resolveAndroidDeviceSerial,
51
47
  runESVPActions,
48
+ runMaestroTest,
49
+ tapViaIdb,
52
50
  validateESVPReplay
53
- } from "./chunk-GAKEFJ5T.js";
51
+ } from "./chunk-7EDIUVIO.js";
52
+ import {
53
+ APP_VERSION
54
+ } from "./chunk-6EGBXRDK.js";
54
55
  import {
55
56
  redactQuotedStringsInText,
56
57
  redactSensitiveTestInput
@@ -239,7 +240,9 @@ async function startRender(projectId, templateId, props, onProgress) {
239
240
  }
240
241
  async function renderAsync(job, bundlePath, templateId, compositionId, props, onProgress) {
241
242
  job.status = "rendering";
243
+ const originalCwd = process.cwd();
242
244
  try {
245
+ process.chdir(DATA_DIR);
243
246
  const { selectComposition, renderMedia } = await import("@remotion/renderer");
244
247
  const realVideoDuration = getVideoDuration(props.videoUrl);
245
248
  const optimizedProps = optimizeTemplatePropsForRender(templateId, props, realVideoDuration);
@@ -278,6 +281,8 @@ async function renderAsync(job, bundlePath, templateId, compositionId, props, on
278
281
  job.error = err.message;
279
282
  job.completedAt = Date.now();
280
283
  throw err;
284
+ } finally {
285
+ process.chdir(originalCwd);
281
286
  }
282
287
  }
283
288
  function getVideoDuration(videoUrl) {
@@ -4589,9 +4594,20 @@ function resolveProjectESVPSessionId(esvp) {
4589
4594
  return validationId || networkId || directId || null;
4590
4595
  }
4591
4596
  function resolveProjectESVPServerUrl(esvp) {
4592
- if (!esvp || typeof esvp.serverUrl !== "string") return void 0;
4593
- const serverUrl = esvp.serverUrl.trim();
4594
- return serverUrl || void 0;
4597
+ return normalizePersistedLocalESVPServerUrl(esvp?.serverUrl, esvp?.connectionMode);
4598
+ }
4599
+ function normalizePersistedLocalESVPServerUrl(serverUrl, connectionMode) {
4600
+ const normalized = typeof serverUrl === "string" ? serverUrl.trim().replace(/\/+$/, "") : "";
4601
+ if (normalized === LOCAL_ESVP_SERVER_URL) {
4602
+ return LOCAL_ESVP_SERVER_URL;
4603
+ }
4604
+ if (!normalized && connectionMode === "local") {
4605
+ return LOCAL_ESVP_SERVER_URL;
4606
+ }
4607
+ return void 0;
4608
+ }
4609
+ function resolvePersistedLocalESVPServerUrl(serverUrl, esvp) {
4610
+ return normalizePersistedLocalESVPServerUrl(serverUrl, "local") || resolveProjectESVPServerUrl(esvp) || LOCAL_ESVP_SERVER_URL;
4595
4611
  }
4596
4612
  function isESVPReplayValidationSupported(result) {
4597
4613
  if (!result) return true;
@@ -6433,7 +6449,7 @@ app.post("/api/testing/mobile/recordings/:id/esvp/validate", async (c) => {
6433
6449
  let autoSynced = false;
6434
6450
  if (result.networkEntries.length === 0 && result.supported && result.sourceSessionId && ((result.networkState?.managed_proxy?.entry_count ?? 0) > 0 || (result.networkState?.trace_count ?? 0) > 0)) {
6435
6451
  try {
6436
- const { collectESVPSessionNetworkData: collectESVPSessionNetworkData2 } = await import("./esvp-mobile-GC7MAGMI.js");
6452
+ const { collectESVPSessionNetworkData: collectESVPSessionNetworkData2 } = await import("./esvp-mobile-GZ5EMYPG.js");
6437
6453
  const deferred = await collectESVPSessionNetworkData2(result.sourceSessionId, serverUrl);
6438
6454
  if (deferred.networkEntries.length > 0) {
6439
6455
  session.networkEntries = deferred.networkEntries;
@@ -6517,7 +6533,7 @@ app.post("/api/testing/mobile/recordings/:id/esvp/replay", async (c) => {
6517
6533
  session.esvp = {
6518
6534
  ...esvp || {},
6519
6535
  currentSessionId: sourceSessionId,
6520
- serverUrl: serverUrl || (typeof esvp?.serverUrl === "string" ? esvp.serverUrl : null),
6536
+ serverUrl: resolvePersistedLocalESVPServerUrl(serverUrl, esvp),
6521
6537
  executor,
6522
6538
  validation: {
6523
6539
  ...existingValidation,
@@ -6660,7 +6676,10 @@ app.post("/api/testing/mobile/recordings/:id/esvp/network/start", async (c) => {
6660
6676
  ...session.esvp && typeof session.esvp === "object" ? session.esvp : {},
6661
6677
  currentSessionId: sourceSessionId,
6662
6678
  connectionMode: typeof session?.esvp?.connectionMode === "string" ? session.esvp.connectionMode : "local",
6663
- serverUrl: serverUrl || (typeof session?.esvp?.serverUrl === "string" ? session.esvp.serverUrl : null),
6679
+ serverUrl: resolvePersistedLocalESVPServerUrl(
6680
+ serverUrl,
6681
+ session?.esvp && typeof session.esvp === "object" ? session.esvp : null
6682
+ ),
6664
6683
  executor,
6665
6684
  network: {
6666
6685
  ...existingNetwork || {},
@@ -9938,11 +9957,11 @@ app.post("/api/templates/props", async (c) => {
9938
9957
  if (!project) {
9939
9958
  return c.json({ error: "Project not found" }, 404);
9940
9959
  }
9941
- const props = assembleTemplateProps(project);
9942
- if (!props) {
9960
+ const templateState = getTemplateProjectState(project);
9961
+ if (!templateState) {
9943
9962
  return c.json({ error: "Project has no video to render" }, 400);
9944
9963
  }
9945
- return c.json({ props });
9964
+ return c.json(templateState);
9946
9965
  } catch (error) {
9947
9966
  const message = error instanceof Error ? error.message : "Unknown error";
9948
9967
  return c.json({ error: message }, 500);
@@ -10000,26 +10019,39 @@ app.put("/api/projects/:id/template-content", async (c) => {
10000
10019
  try {
10001
10020
  const id = c.req.param("id");
10002
10021
  const body = await c.req.json();
10003
- const { title, titleLines, terminalTabs, showcaseMode } = body;
10022
+ const { title, titleLines, terminalTabs, showcaseMode, deviceMockup } = body;
10004
10023
  const db = getDatabase();
10005
10024
  const project = db.select().from(projects).where(eq(projects.id, id)).get();
10006
10025
  if (!project) {
10007
10026
  return c.json({ error: "Project not found" }, 404);
10008
10027
  }
10028
+ const defaultTitle = sanitizeTemplateTitle(
10029
+ extractFirstSentence(project.aiSummary || project.name || "App Recording"),
10030
+ "App Recording"
10031
+ );
10032
+ const sanitizedTitle = sanitizeTemplateTitle(title, defaultTitle);
10033
+ const sanitizedTitleLines = sanitizeTemplateTitleLines(titleLines, sanitizedTitle);
10034
+ const sanitizedTerminalTabs = sanitizeTemplateTerminalTabs(terminalTabs);
10035
+ const sanitizedShowcaseMode = showcaseMode === "artistic" || showcaseMode === "terminal" ? showcaseMode : void 0;
10036
+ const platform = project.platform === "ios" || project.platform === "android" || project.platform === "web" ? project.platform : "web";
10037
+ const availableAndroidMockups = listAndroidDeviceMockupIds();
10038
+ const sanitizedDeviceMockup = platform === "android" ? resolveAndroidDeviceMockup(deviceMockup, availableAndroidMockups) : void 0;
10039
+ const savedContent = {
10040
+ title: sanitizedTitle,
10041
+ titleLines: sanitizedTitleLines,
10042
+ terminalTabs: sanitizedTerminalTabs,
10043
+ showcaseMode: sanitizedShowcaseMode,
10044
+ deviceMockup: sanitizedDeviceMockup,
10045
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10046
+ };
10009
10047
  const { writeFileSync: writeFileSync5, mkdirSync: mkdirSync6 } = await import("fs");
10010
10048
  const projectDir = join5(PROJECTS_DIR, id);
10011
10049
  if (!existsSync5(projectDir)) {
10012
10050
  mkdirSync6(projectDir, { recursive: true });
10013
10051
  }
10014
10052
  const contentPath = join5(projectDir, "template-content.json");
10015
- writeFileSync5(contentPath, JSON.stringify({
10016
- title,
10017
- titleLines,
10018
- terminalTabs,
10019
- showcaseMode,
10020
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10021
- }));
10022
- return c.json({ success: true, message: "Template content saved" });
10053
+ writeFileSync5(contentPath, JSON.stringify(savedContent));
10054
+ return c.json({ success: true, message: "Template content saved", content: savedContent });
10023
10055
  } catch (error) {
10024
10056
  const message = error instanceof Error ? error.message : "Unknown error";
10025
10057
  return c.json({ error: message }, 500);
@@ -10040,10 +10072,14 @@ app.post("/api/templates/render", async (c) => {
10040
10072
  if (!project) {
10041
10073
  return c.json({ error: "Project not found" }, 404);
10042
10074
  }
10043
- const props = assembleTemplateProps(project);
10044
- if (!props) {
10075
+ const templateState = getTemplateProjectState(project);
10076
+ if (!templateState) {
10045
10077
  return c.json({ error: "Project has no video to render" }, 400);
10046
10078
  }
10079
+ if (!templateState.eligibility.templatesAllowed) {
10080
+ return c.json({ error: templateState.eligibility.reason || `Templates are limited to videos up to ${TEMPLATE_MAX_DURATION_SECONDS} seconds.` }, 400);
10081
+ }
10082
+ const { props } = templateState;
10047
10083
  const job = await startRender(projectId, templateId, props, (progress) => {
10048
10084
  broadcastToClients({
10049
10085
  type: "templateRenderProgress",
@@ -10139,29 +10175,27 @@ app.get("/api/settings/template-preference", async (c) => {
10139
10175
  }
10140
10176
  });
10141
10177
  function assembleTemplateProps(project) {
10142
- const resolvedVideoPath = resolveVideoPath(project.videoPath);
10143
- if (!resolvedVideoPath || !existsSync5(resolvedVideoPath)) {
10178
+ const resolvedVideoPath = resolveTemplateVideoPath(project);
10179
+ if (!resolvedVideoPath) {
10144
10180
  return null;
10145
10181
  }
10146
- try {
10147
- if (statSync2(resolvedVideoPath).isDirectory()) {
10148
- return null;
10149
- }
10150
- } catch {
10151
- return null;
10152
- }
10153
- const defaultTitle = extractFirstSentence(project.aiSummary || project.name || "App Recording");
10182
+ const defaultTitle = sanitizeTemplateTitle(
10183
+ extractFirstSentence(project.aiSummary || project.name || "App Recording"),
10184
+ "App Recording"
10185
+ );
10154
10186
  const subtitle = project.name || void 0;
10155
10187
  const editedContent = loadEditedTemplateContent(project.id);
10188
+ const availableAndroidMockups = listAndroidDeviceMockupIds();
10156
10189
  let title = defaultTitle;
10157
10190
  let titleLines;
10158
10191
  let terminalTabs = [];
10159
10192
  let hasNetworkData = false;
10160
10193
  let showcaseMode;
10194
+ let deviceMockup;
10161
10195
  if (editedContent) {
10162
- title = editedContent.title || defaultTitle;
10163
- titleLines = editedContent.titleLines;
10164
- terminalTabs = editedContent.terminalTabs || [];
10196
+ title = sanitizeTemplateTitle(editedContent.title || defaultTitle, defaultTitle);
10197
+ titleLines = sanitizeTemplateTitleLines(editedContent.titleLines, title);
10198
+ terminalTabs = sanitizeTemplateTerminalTabs(editedContent.terminalTabs);
10165
10199
  hasNetworkData = terminalTabs.length > 0;
10166
10200
  showcaseMode = editedContent.showcaseMode;
10167
10201
  } else {
@@ -10171,8 +10205,14 @@ function assembleTemplateProps(project) {
10171
10205
  terminalTabs = groupNetworkIntoTabs(networkEntries);
10172
10206
  }
10173
10207
  }
10174
- const videoDuration = project.duration || 0;
10175
10208
  const platform = project.platform === "ios" || project.platform === "android" || project.platform === "web" ? project.platform : "web";
10209
+ if (platform === "android") {
10210
+ deviceMockup = resolveAndroidDeviceMockup(editedContent?.deviceMockup, availableAndroidMockups);
10211
+ }
10212
+ if (!titleLines && (!showcaseMode || showcaseMode === "artistic") && !hasNetworkData) {
10213
+ titleLines = splitTemplateTitleIntoLines(title);
10214
+ }
10215
+ const videoDuration = getActualTemplateVideoDuration(project, resolvedVideoPath);
10176
10216
  const videoUrl = `http://localhost:${currentServerPort}/api/file?path=${encodeURIComponent(resolvedVideoPath)}`;
10177
10217
  return {
10178
10218
  videoUrl,
@@ -10183,7 +10223,32 @@ function assembleTemplateProps(project) {
10183
10223
  subtitle,
10184
10224
  terminalTabs,
10185
10225
  hasNetworkData,
10186
- showcaseMode
10226
+ showcaseMode,
10227
+ deviceMockup
10228
+ };
10229
+ }
10230
+ var TEMPLATE_MAX_DURATION_SECONDS = 60;
10231
+ var DEFAULT_ANDROID_DEVICE_MOCKUP = "mockup-android-galaxy.png";
10232
+ var ANDROID_DEVICE_MOCKUP_FALLBACKS = [
10233
+ DEFAULT_ANDROID_DEVICE_MOCKUP,
10234
+ "mockup-android.png",
10235
+ "mockup-android-google-pixel-9-pro.png"
10236
+ ];
10237
+ function getTemplateProjectState(project) {
10238
+ const props = assembleTemplateProps(project);
10239
+ if (!props) {
10240
+ return null;
10241
+ }
10242
+ const eligibility = buildTemplateEligibility(props.videoDuration);
10243
+ const androidDeviceMockups = listAndroidDeviceMockupIds().map((id) => ({
10244
+ id,
10245
+ label: formatAndroidDeviceMockupLabel(id)
10246
+ }));
10247
+ return {
10248
+ props,
10249
+ eligibility,
10250
+ androidDeviceMockups,
10251
+ defaultAndroidDeviceMockup: resolveAndroidDeviceMockup(void 0, androidDeviceMockups.map((option) => option.id))
10187
10252
  };
10188
10253
  }
10189
10254
  function extractFirstSentence(text) {
@@ -10192,6 +10257,164 @@ function extractFirstSentence(text) {
10192
10257
  if (match) return match[0].trim();
10193
10258
  return text.substring(0, 80).trim();
10194
10259
  }
10260
+ function resolveTemplateVideoPath(project) {
10261
+ const resolvedVideoPath = resolveVideoPath(project.videoPath);
10262
+ if (!resolvedVideoPath || !existsSync5(resolvedVideoPath)) {
10263
+ return null;
10264
+ }
10265
+ try {
10266
+ if (statSync2(resolvedVideoPath).isDirectory()) {
10267
+ return null;
10268
+ }
10269
+ } catch {
10270
+ return null;
10271
+ }
10272
+ return resolvedVideoPath;
10273
+ }
10274
+ function getActualTemplateVideoDuration(project, resolvedVideoPath) {
10275
+ const probedDuration = probeVideoDurationSeconds(resolvedVideoPath);
10276
+ if (probedDuration && probedDuration > 0) {
10277
+ return probedDuration;
10278
+ }
10279
+ const projectDuration = Number(project?.duration);
10280
+ if (Number.isFinite(projectDuration) && projectDuration > 0) {
10281
+ return projectDuration;
10282
+ }
10283
+ return 0;
10284
+ }
10285
+ function probeVideoDurationSeconds(filePath) {
10286
+ try {
10287
+ if (!existsSync5(filePath)) return null;
10288
+ const output = execSync3(
10289
+ `ffprobe -v quiet -print_format json -show_format "${filePath}"`,
10290
+ { encoding: "utf-8", timeout: 1e4 }
10291
+ );
10292
+ const data = JSON.parse(output);
10293
+ const duration = parseFloat(data?.format?.duration || "0");
10294
+ return Number.isFinite(duration) && duration > 0 ? duration : null;
10295
+ } catch {
10296
+ return null;
10297
+ }
10298
+ }
10299
+ function buildTemplateEligibility(actualDurationSeconds) {
10300
+ const templatesAllowed = isTemplateDurationAllowed(actualDurationSeconds);
10301
+ return {
10302
+ templatesAllowed,
10303
+ maxTemplateDurationSeconds: TEMPLATE_MAX_DURATION_SECONDS,
10304
+ actualDurationSeconds,
10305
+ reason: templatesAllowed || actualDurationSeconds <= 0 ? void 0 : `Templates are limited to videos up to ${TEMPLATE_MAX_DURATION_SECONDS} seconds. This recording is ${formatTemplateDuration(actualDurationSeconds)}.`
10306
+ };
10307
+ }
10308
+ function isTemplateDurationAllowed(actualDurationSeconds) {
10309
+ if (!Number.isFinite(actualDurationSeconds) || actualDurationSeconds <= 0) {
10310
+ return true;
10311
+ }
10312
+ return Math.round(actualDurationSeconds * 1e3) <= TEMPLATE_MAX_DURATION_SECONDS * 1e3;
10313
+ }
10314
+ function formatTemplateDuration(actualDurationSeconds) {
10315
+ if (!Number.isFinite(actualDurationSeconds) || actualDurationSeconds <= 0) {
10316
+ return "unknown duration";
10317
+ }
10318
+ const totalSeconds = Math.max(1, Math.round(actualDurationSeconds));
10319
+ const minutes = Math.floor(totalSeconds / 60);
10320
+ const seconds = totalSeconds % 60;
10321
+ return minutes > 0 ? `${minutes}m ${seconds}s` : `${totalSeconds}s`;
10322
+ }
10323
+ function sanitizeTemplateTitle(value, fallback = "App Recording") {
10324
+ const normalized = normalizeTemplateTitle(value);
10325
+ if (normalized) return normalized;
10326
+ const safeFallback = normalizeTemplateTitle(fallback);
10327
+ return safeFallback || "App Recording";
10328
+ }
10329
+ function normalizeTemplateTitle(value) {
10330
+ if (typeof value !== "string") return "";
10331
+ const withoutMarkdown = value.replace(/[#*_`~>\-[\]{}()<>\\/|]+/g, " ").replace(/&/g, " and ").replace(/[^\p{L}\p{N}\s]/gu, " ").replace(/\s+/g, " ").trim();
10332
+ if (!withoutMarkdown) return "";
10333
+ const limitedWords = withoutMarkdown.split(" ").filter(Boolean).slice(0, 7).join(" ");
10334
+ if (!limitedWords) return "";
10335
+ if (limitedWords.length <= 48) return limitedWords;
10336
+ const truncated = limitedWords.slice(0, 48).trim();
10337
+ return truncated.replace(/\s+\S*$/, "").trim() || truncated;
10338
+ }
10339
+ function sanitizeTemplateTitleLines(value, fallbackTitle) {
10340
+ if (!Array.isArray(value)) {
10341
+ return void 0;
10342
+ }
10343
+ const cleaned = value.map((line) => sanitizeTemplateTitle(line, "")).filter(Boolean).slice(0, 4);
10344
+ if (cleaned.length > 0) {
10345
+ return cleaned;
10346
+ }
10347
+ return splitTemplateTitleIntoLines(fallbackTitle);
10348
+ }
10349
+ function splitTemplateTitleIntoLines(title) {
10350
+ const words = title.split(" ").filter(Boolean);
10351
+ if (words.length === 0) return void 0;
10352
+ if (words.length <= 2) return [title];
10353
+ const lines = [];
10354
+ for (let index = 0; index < words.length && lines.length < 4; index += 2) {
10355
+ lines.push(words.slice(index, index + 2).join(" "));
10356
+ }
10357
+ return lines;
10358
+ }
10359
+ function sanitizeTemplateTerminalTabs(value) {
10360
+ if (!Array.isArray(value)) return [];
10361
+ return value.map((tab) => {
10362
+ if (!tab || typeof tab !== "object") return null;
10363
+ const record = tab;
10364
+ const label = typeof record.label === "string" ? record.label.trim() : "";
10365
+ const methodFromLabel = label.split(" ")[0] || "GET";
10366
+ const routeFromLabel = label.split(" ").slice(1).join(" ") || "/";
10367
+ const method = typeof record.method === "string" && record.method.trim() ? record.method.trim().toUpperCase() : methodFromLabel.toUpperCase();
10368
+ const route = typeof record.route === "string" && record.route.trim() ? record.route.trim() : routeFromLabel;
10369
+ const content = typeof record.content === "string" ? record.content : "";
10370
+ if (!label && !content.trim()) return null;
10371
+ return {
10372
+ label: label || `${method} ${route}`.trim(),
10373
+ method: method || "GET",
10374
+ route: route || "/",
10375
+ content
10376
+ };
10377
+ }).filter((tab) => !!tab);
10378
+ }
10379
+ function listAndroidDeviceMockupIds() {
10380
+ const bundlePath = getBundlePath();
10381
+ if (!bundlePath) {
10382
+ return [...ANDROID_DEVICE_MOCKUP_FALLBACKS];
10383
+ }
10384
+ const publicDir = join5(bundlePath, "public");
10385
+ if (!existsSync5(publicDir)) {
10386
+ return [...ANDROID_DEVICE_MOCKUP_FALLBACKS];
10387
+ }
10388
+ const files = readdirSync2(publicDir).filter((file) => /^mockup-android.*\.png$/i.test(file));
10389
+ const unique = new Set(files.length > 0 ? files : ANDROID_DEVICE_MOCKUP_FALLBACKS);
10390
+ return [...unique].sort((left, right) => {
10391
+ const leftIndex = ANDROID_DEVICE_MOCKUP_FALLBACKS.indexOf(left);
10392
+ const rightIndex = ANDROID_DEVICE_MOCKUP_FALLBACKS.indexOf(right);
10393
+ if (leftIndex !== -1 || rightIndex !== -1) {
10394
+ if (leftIndex === -1) return 1;
10395
+ if (rightIndex === -1) return -1;
10396
+ return leftIndex - rightIndex;
10397
+ }
10398
+ return left.localeCompare(right);
10399
+ });
10400
+ }
10401
+ function resolveAndroidDeviceMockup(requested, available) {
10402
+ const candidate = typeof requested === "string" ? requested.trim() : "";
10403
+ if (candidate && available.includes(candidate)) {
10404
+ return candidate;
10405
+ }
10406
+ for (const fallback of ANDROID_DEVICE_MOCKUP_FALLBACKS) {
10407
+ if (available.includes(fallback)) {
10408
+ return fallback;
10409
+ }
10410
+ }
10411
+ return available[0] || DEFAULT_ANDROID_DEVICE_MOCKUP;
10412
+ }
10413
+ function formatAndroidDeviceMockupLabel(filename) {
10414
+ const base = filename.replace(/^mockup-android-?/i, "").replace(/\.png$/i, "");
10415
+ if (!base) return "Android";
10416
+ return base.split("-").filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
10417
+ }
10195
10418
  function isTunnelLikeNetworkEntry(entry) {
10196
10419
  if (!entry || typeof entry !== "object") return false;
10197
10420
  const method = typeof entry.method === "string" ? entry.method.toUpperCase() : "";
@@ -10331,7 +10554,8 @@ function loadEditedTemplateContent(projectId) {
10331
10554
  title: data.title,
10332
10555
  titleLines: data.titleLines,
10333
10556
  terminalTabs: data.terminalTabs,
10334
- showcaseMode: data.showcaseMode
10557
+ showcaseMode: data.showcaseMode,
10558
+ deviceMockup: data.deviceMockup
10335
10559
  };
10336
10560
  } catch {
10337
10561
  return null;
@@ -0,0 +1,94 @@
1
+ import {
2
+ TEMPLATES_DIR
3
+ } from "./chunk-VVIOB362.js";
4
+
5
+ // src/core/templates/loader.ts
6
+ import { existsSync, readFileSync } from "fs";
7
+ import { join, dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ var MANIFEST_FILE = "manifest.json";
10
+ var BUNDLE_DIR = "bundle";
11
+ var __dirname = dirname(fileURLToPath(import.meta.url));
12
+ var BUNDLED_TEMPLATES_DIR_CANDIDATES = [
13
+ join(__dirname, "templates"),
14
+ join(__dirname, "..", "templates"),
15
+ join(__dirname, "..", "..", "templates")
16
+ ];
17
+ var cachedManifest = null;
18
+ var cachedTemplatesDir = null;
19
+ var cachedAt = 0;
20
+ var CACHE_TTL = 3e4;
21
+ function resolveTemplatesDir() {
22
+ const explicitOverride = process.env.DISCOVERYLAB_TEMPLATE_DIR?.trim() || process.env.DISCOVERYLAB_TEMPLATE_SOURCE_DIR?.trim();
23
+ if (explicitOverride) {
24
+ const overrideManifest = join(explicitOverride, MANIFEST_FILE);
25
+ const overrideBundle = join(explicitOverride, BUNDLE_DIR);
26
+ if (existsSync(overrideManifest) && existsSync(overrideBundle)) {
27
+ return explicitOverride;
28
+ }
29
+ }
30
+ for (const candidate of BUNDLED_TEMPLATES_DIR_CANDIDATES) {
31
+ const bundledManifest = join(candidate, MANIFEST_FILE);
32
+ const bundledBundle = join(candidate, BUNDLE_DIR);
33
+ if (existsSync(bundledManifest) && existsSync(bundledBundle)) {
34
+ return candidate;
35
+ }
36
+ }
37
+ const localManifest = join(TEMPLATES_DIR, MANIFEST_FILE);
38
+ const localBundle = join(TEMPLATES_DIR, BUNDLE_DIR);
39
+ if (existsSync(localManifest) && existsSync(localBundle)) {
40
+ return TEMPLATES_DIR;
41
+ }
42
+ return null;
43
+ }
44
+ function loadManifest() {
45
+ const now = Date.now();
46
+ if (cachedManifest && now - cachedAt < CACHE_TTL) {
47
+ return cachedManifest;
48
+ }
49
+ const dir = resolveTemplatesDir();
50
+ if (!dir) {
51
+ cachedManifest = null;
52
+ cachedTemplatesDir = null;
53
+ cachedAt = now;
54
+ return null;
55
+ }
56
+ try {
57
+ const raw = readFileSync(join(dir, MANIFEST_FILE), "utf-8");
58
+ const manifest = JSON.parse(raw);
59
+ cachedManifest = manifest;
60
+ cachedTemplatesDir = dir;
61
+ cachedAt = now;
62
+ return manifest;
63
+ } catch {
64
+ cachedManifest = null;
65
+ cachedTemplatesDir = null;
66
+ cachedAt = now;
67
+ return null;
68
+ }
69
+ }
70
+ function isTemplatesInstalled() {
71
+ return loadManifest() !== null;
72
+ }
73
+ function getAvailableTemplates() {
74
+ const manifest = loadManifest();
75
+ return manifest?.templates ?? [];
76
+ }
77
+ function getTemplate(id) {
78
+ const templates = getAvailableTemplates();
79
+ return templates.find((t) => t.id === id) ?? null;
80
+ }
81
+ function getBundlePath() {
82
+ loadManifest();
83
+ if (!cachedTemplatesDir) return null;
84
+ const bundlePath = join(cachedTemplatesDir, BUNDLE_DIR);
85
+ if (!existsSync(bundlePath)) return null;
86
+ return bundlePath;
87
+ }
88
+
89
+ export {
90
+ isTemplatesInstalled,
91
+ getAvailableTemplates,
92
+ getTemplate,
93
+ getBundlePath
94
+ };
@@ -15,7 +15,7 @@ import {
15
15
  inspectESVPSession,
16
16
  replayESVPSession,
17
17
  runESVPActions
18
- } from "./chunk-GAKEFJ5T.js";
18
+ } from "./chunk-7EDIUVIO.js";
19
19
  import {
20
20
  redactSensitiveTestInput
21
21
  } from "./chunk-SLNJEF32.js";