issue-sticker 0.12.0 → 0.13.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.
@@ -1,5 +1,6 @@
1
- import { u as uploadScreenshotDataUrls, a as uploadSignedMediaWithApi, d as directUploadToSignedUrl, p as pristineFetch, g as getElementPosition, c as captureViewportWithElementCrop, b as captureWithModernScreenshot, e as buildJiraCreateFields, f as buildNotionCreateFields, h as buildGuestOwnerQuery, i as composeIssueFilterGroups, j as buildIssueIntegrationsPayload, k as devLogger, W as WebSocketCollector, C as ConsoleCollector, N as NetworkCollector, s as selectNetworkLogsForSubmit, l as filterExcludeServiceRelatedLogs, m as filterExcludeServiceRelatedConsoleLogs, n as compactNetworkLogsForTransport, o as CONSOLE_LOG_SUBMIT_LIMIT, q as buildExternalUserPayload, r as shrinkNetworkLogsToFit, t as measureJsonBytes, v as sanitizeString, w as collectAttachmentUploads, x as hasReplayUpload, y as getCurrentPageUrl, z as createIssueUploadQueues, S as ScreenRecordingController, A as SessionReplayCaptureController, B as preloadModernScreenshot, D as enqueueIssueUploads, E as normalizeUploadMimeType, I as ISSUE_STICKER_INTERNAL_LOG_PREFIX, F as toSessionReplayMeta, G as loadTusClient, H as createIndexedDbPendingUploadStore, J as ATTACHMENT_UPLOAD_DB_NAME, V as VIDEO_UPLOAD_DB_NAME, K as createMediaUploadApi } from "./externalUserPayload-DWcXyz2U.js";
2
- import { r } from "./three-B228j4M6.js";
1
+ import { u as uploadScreenshotDataUrls, a as uploadSignedMediaWithApi, d as directUploadToSignedUrl, p as pristineFetch, g as getElementPosition, c as captureViewportAndElementShots, b as captureScreenshotWithMasks, i as isCapturedScreenshot, M as MaskingCaptureError, e as buildJiraCreateFields, f as buildNotionCreateFields, h as buildGuestOwnerQuery, j as composeIssueFilterGroups, k as buildIssueIntegrationsPayload, l as devLogger, W as WebSocketCollector, C as ConsoleCollector, N as NetworkCollector, s as selectNetworkLogsForSubmit, m as filterExcludeServiceRelatedLogs, n as filterExcludeServiceRelatedConsoleLogs, o as compactNetworkLogsForTransport, q as CONSOLE_LOG_SUBMIT_LIMIT, r as buildExternalUserPayload, t as shrinkNetworkLogsToFit, v as measureJsonBytes, w as sanitizeString, x as runMaskedSubmit, y as applySubmitMasking, z as collectAttachmentUploads, A as maskPositionForSubmit, B as hasReplayUpload, D as appendMaskingNotice, E as screenshotExclusionNotice, F as collectSnapshotSelectors, G as MaskingScreenRulesChangedError, H as screenRulesChangedMessage, I as MaskingPolicyUnavailableError, J as MaskingSelectorUnsupportedError, K as assertScreenRulesCovered, L as isMaskingFailure, O as maskScreenshotsForSubmit, P as getCurrentPageUrl, Q as createIssueUploadQueues, S as ScreenRecordingController, R as SessionReplayCaptureController, T as preloadModernScreenshot, U as enqueueIssueUploads, V as normalizeUploadMimeType, X as ISSUE_STICKER_INTERNAL_LOG_PREFIX, Y as MASKING_POLICY_PREFETCH_TIMEOUT_MS, Z as toSessionReplayMeta, _ as loadCaptureSelectors, $ as loadMaskingPolicyForSubmit, a0 as resolveEffectivePolicy, a1 as createMaskingPolicySource, a2 as loadTusClient, a3 as createIndexedDbPendingUploadStore, a4 as ATTACHMENT_UPLOAD_DB_NAME, a5 as VIDEO_UPLOAD_DB_NAME, a6 as createMediaUploadApi, a7 as createMemoryMaskingPolicyCache } from "./externalUserPayload-CdzgCq4t.js";
2
+ import { M as MASKING_COPY, R as ReplayMaskingError } from "./three-FamoZZKK.js";
3
+ import { r } from "./three-FamoZZKK.js";
3
4
  import "react/jsx-runtime";
4
5
  import "react";
5
6
  const WINDOW_MS = 6e4;
@@ -99,7 +100,8 @@ class HeadlessTransport {
99
100
  const execute = (nextHeaders) => pristineFetch(`${getApiBaseUrl()}${endpoint}`, {
100
101
  method: options.method ?? "GET",
101
102
  headers: nextHeaders,
102
- body: options.body === void 0 ? void 0 : JSON.stringify(options.body)
103
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
104
+ ...options.cache ? { cache: options.cache } : {}
103
105
  });
104
106
  let response = await execute(headers);
105
107
  if (!options.skipAuth && response.status === 401 && this.options.getAuthMode() === "member") {
@@ -115,8 +117,9 @@ class HeadlessTransport {
115
117
  }
116
118
  const json = await response.json().catch(() => null);
117
119
  if (!response.ok || !(json == null ? void 0 : json.success)) {
120
+ const unauthorizedCode = this.options.getAuthMode() === "member" ? "MEMBER_AUTH_REQUIRED" : "AUTH_FAILED";
118
121
  throw new IssueStickerHeadlessError(
119
- response.status === 401 ? "MEMBER_AUTH_REQUIRED" : "NETWORK_ERROR",
122
+ response.status === 401 ? unauthorizedCode : "NETWORK_ERROR",
120
123
  extractErrorMessage(json == null ? void 0 : json.error, (json == null ? void 0 : json.message) || `요청 실패: ${response.status}`),
121
124
  json,
122
125
  response.status
@@ -473,21 +476,34 @@ function buildHeadlessCaptureContext({
473
476
  sessionReplayPreview: null
474
477
  };
475
478
  }
479
+ const NO_HEADLESS_SCREENSHOTS = {
480
+ screenshots: [],
481
+ maskingDropped: false
482
+ };
476
483
  async function captureHeadlessScreenshots({
477
484
  target,
478
- excludeSelectors
485
+ excludeSelectors,
486
+ maskSelectors
479
487
  }) {
480
488
  const targetIsDocumentRoot = target === document.body || target === document.documentElement;
481
- const { fullDataUrl, elementDataUrl } = await captureViewportWithElementCrop({
489
+ const viewport = await captureViewportAndElementShots({
482
490
  element: target,
483
- excludeSelectors
491
+ excludeSelectors,
492
+ maskSelectors
484
493
  });
485
- const elementShot = targetIsDocumentRoot || elementDataUrl ? elementDataUrl : await captureWithModernScreenshot({ element: target, excludeSelectors }).catch(
486
- () => void 0
487
- );
488
- return [fullDataUrl, elementShot].filter(
489
- (dataUrl) => typeof dataUrl === "string" && dataUrl.length > 0
490
- );
494
+ let maskingDropped = Boolean(viewport.maskingDropped);
495
+ const elementShot = targetIsDocumentRoot || viewport.element ? viewport.element : await captureScreenshotWithMasks({
496
+ element: target,
497
+ excludeSelectors,
498
+ maskSelectors
499
+ }).catch((error) => {
500
+ maskingDropped || (maskingDropped = error instanceof MaskingCaptureError);
501
+ return void 0;
502
+ });
503
+ return {
504
+ screenshots: [viewport.full, elementShot].filter(isCapturedScreenshot),
505
+ maskingDropped
506
+ };
491
507
  }
492
508
  function normalizeCustomInputType(fieldType) {
493
509
  if (fieldType === "number") return "number";
@@ -1087,7 +1103,8 @@ function buildIssueCreatePayload({
1087
1103
  uploads,
1088
1104
  logs,
1089
1105
  currentUrl,
1090
- userAgent
1106
+ userAgent,
1107
+ maskingPolicyVersion
1091
1108
  }) {
1092
1109
  var _a, _b, _c;
1093
1110
  const title = (_a = draft.title) == null ? void 0 : _a.trim();
@@ -1107,6 +1124,7 @@ function buildIssueCreatePayload({
1107
1124
  tenant_id: auth.tenantId,
1108
1125
  project_id: projectId,
1109
1126
  account_id: auth.accountId ?? void 0,
1127
+ masking_policy_version: maskingPolicyVersion,
1110
1128
  ...buildExternalUserPayload(config.user)
1111
1129
  };
1112
1130
  const { networkLogs, stillOverBudget } = shrinkNetworkLogsToFit(
@@ -1121,8 +1139,67 @@ function buildIssueCreatePayload({
1121
1139
  stillOverBudget
1122
1140
  };
1123
1141
  }
1142
+ async function prepareHeadlessScreenAttachments(input) {
1143
+ var _a, _b;
1144
+ const { context } = input;
1145
+ const includeScreenshots = input.draft.mediaMode === "screenshot" || Boolean((_a = context.rules) == null ? void 0 : _a.screen.exclude_video);
1146
+ const screenshots = includeScreenshots ? input.screenshots : [];
1147
+ const sessionReplay = input.draft.sessionReplayEnabled ? input.sessionReplay : null;
1148
+ await (sessionReplay == null ? void 0 : sessionReplay.whenRestarted());
1149
+ const memoSelectors = (sessionReplay == null ? void 0 : sessionReplay.getMaskMemoSelectors()) ?? null;
1150
+ assertScreenRulesCovered(context.rules, [
1151
+ ...screenshots.map((shot) => shot.masks.selectors),
1152
+ ...memoSelectors ? [memoSelectors] : []
1153
+ ]);
1154
+ const replay = sessionReplay ? await sessionReplay.takeSnapshot(((_b = context.rules) == null ? void 0 : _b.screen.mask_selectors) ?? []).catch((error) => {
1155
+ if (isMaskingFailure(error)) throw error;
1156
+ return null;
1157
+ }) : null;
1158
+ const uploads = includeScreenshots ? await input.transport.uploadScreenshotPreviews(
1159
+ await maskScreenshotsForSubmit(screenshots, context.rules)
1160
+ ) : [];
1161
+ return {
1162
+ screenshotUploadIds: uploads.map((upload) => upload.id),
1163
+ replay,
1164
+ screenshotExcluded: includeScreenshots && input.screenshotsDroppedByMasking ? uploads.length > 0 ? "partial" : "all" : null
1165
+ };
1166
+ }
1167
+ function toHeadlessMaskingError(error, options) {
1168
+ var _a;
1169
+ if (error instanceof MaskingScreenRulesChangedError) {
1170
+ (_a = options.onScreenRulesChanged) == null ? void 0 : _a.call(options);
1171
+ return new IssueStickerHeadlessError(
1172
+ "MASKING_POLICY_CHANGED",
1173
+ screenRulesChangedMessage({
1174
+ screenshots: options.recapturesScreenshots,
1175
+ replay: options.replayRecording
1176
+ }),
1177
+ error
1178
+ );
1179
+ }
1180
+ if (error instanceof MaskingPolicyUnavailableError) {
1181
+ return new IssueStickerHeadlessError(
1182
+ "MASKING_POLICY_UNAVAILABLE",
1183
+ MASKING_COPY.policyUnavailable,
1184
+ error
1185
+ );
1186
+ }
1187
+ if (error instanceof MaskingSelectorUnsupportedError) {
1188
+ return new IssueStickerHeadlessError(
1189
+ "MASKING_SELECTOR_UNSUPPORTED",
1190
+ MASKING_COPY.selectorUnsupported,
1191
+ error
1192
+ );
1193
+ }
1194
+ if (error instanceof ReplayMaskingError) {
1195
+ options.restartReplay();
1196
+ return new IssueStickerHeadlessError("CAPTURE_FAILED", MASKING_COPY.replayMaskFailed, error);
1197
+ }
1198
+ return error;
1199
+ }
1124
1200
  async function submitHeadlessIssueDraft({
1125
1201
  draft,
1202
+ target,
1126
1203
  pendingVideoFile,
1127
1204
  transport,
1128
1205
  issueRepository,
@@ -1133,39 +1210,104 @@ async function submitHeadlessIssueDraft({
1133
1210
  logs,
1134
1211
  currentUrl,
1135
1212
  userAgent,
1136
- takeSessionReplaySnapshot,
1137
- getSocketConnections
1213
+ screenshots,
1214
+ screenshotsDroppedByMasking,
1215
+ sessionReplay,
1216
+ getSocketConnections,
1217
+ maskingPolicy,
1218
+ onScreenRulesChanged,
1219
+ recapturesScreenshots
1138
1220
  }) {
1139
- const screenshotUploads = draft.mediaMode === "screenshot" ? await transport.uploadScreenshotPreviews(draft.screenshotPreviewUrls) : [];
1140
- const screenshotUploadIds = screenshotUploads.map((upload) => upload.id);
1141
- const attachments = await collectAttachmentUploads({
1142
- takeReplay: draft.sessionReplayEnabled ? takeSessionReplaySnapshot : void 0,
1143
- getConnections: getSocketConnections
1144
- });
1145
- const { payload: issueData, stillOverBudget } = buildIssueCreatePayload({
1146
- draft,
1147
- auth,
1148
- config,
1149
- projectId,
1150
- uploads: {
1151
- screenshotUploadIds,
1152
- videoPending: draft.mediaMode === "video" && Boolean(pendingVideoFile),
1153
- replayPending: hasReplayUpload(attachments)
1154
- },
1155
- logs,
1156
- currentUrl,
1157
- userAgent
1158
- });
1159
- if (stillOverBudget) {
1160
- throw new IssueStickerHeadlessError(
1161
- "PAYLOAD_TOO_LARGE",
1162
- "이슈 데이터가 전송 한도를 넘어 저장할 수 없습니다. 설명을 줄이거나 첨부를 빼고 다시 시도해주세요."
1163
- );
1164
- }
1221
+ const hasVideo = draft.mediaMode === "video" && Boolean(pendingVideoFile);
1222
+ const socketConnections = getSocketConnections();
1165
1223
  const integrations = await issueRepository.buildIntegrationsPayload(draft, formSchema);
1166
- const issue = await issueRepository.createIssue({ ...issueData, integrations });
1167
- await issueRepository.syncDynamicFields(issue, draft, formSchema);
1168
- return { issue, attachments };
1224
+ let result;
1225
+ try {
1226
+ result = await runMaskedSubmit({
1227
+ source: maskingPolicy,
1228
+ projectId,
1229
+ prepareScreen: (context) => prepareHeadlessScreenAttachments({
1230
+ context,
1231
+ draft,
1232
+ transport,
1233
+ screenshots,
1234
+ screenshotsDroppedByMasking,
1235
+ sessionReplay
1236
+ }),
1237
+ submit: async (context, screen) => {
1238
+ var _a, _b, _c;
1239
+ const masked = applySubmitMasking({
1240
+ snapshot: context.snapshot,
1241
+ projectId,
1242
+ networkLogs: logs.networkLogs,
1243
+ consoleLogs: logs.consoleLogs,
1244
+ socketConnections,
1245
+ pageUrl: currentUrl
1246
+ });
1247
+ const videoExcluded = hasVideo && Boolean((_a = masked.rules) == null ? void 0 : _a.screen.exclude_video);
1248
+ const replay = screen.replay;
1249
+ const attachments = await collectAttachmentUploads({
1250
+ takeReplay: replay ? async () => replay : void 0,
1251
+ getConnections: () => masked.socketConnections
1252
+ });
1253
+ const maskSelectors = ((_b = context.rules) == null ? void 0 : _b.screen.mask_selectors) ?? [];
1254
+ const position = {
1255
+ ...draft.position,
1256
+ ...maskPositionForSubmit(draft.position, target, maskSelectors)
1257
+ };
1258
+ const { payload: issueData, stillOverBudget } = buildIssueCreatePayload({
1259
+ draft: {
1260
+ ...draft,
1261
+ position,
1262
+ // 마스킹 정책 때문에 버린 스크린샷은 이슈에도 남긴다. 말없이 빼지 않는다.
1263
+ ...screen.screenshotExcluded ? {
1264
+ description: appendMaskingNotice(
1265
+ draft.description,
1266
+ screenshotExclusionNotice(screen.screenshotExcluded)
1267
+ )
1268
+ } : {}
1269
+ },
1270
+ auth,
1271
+ config,
1272
+ projectId,
1273
+ uploads: {
1274
+ screenshotUploadIds: screen.screenshotUploadIds,
1275
+ videoPending: hasVideo && !videoExcluded,
1276
+ replayPending: hasReplayUpload(attachments)
1277
+ },
1278
+ logs: { networkLogs: masked.networkLogs, consoleLogs: masked.consoleLogs },
1279
+ currentUrl: masked.pageUrl,
1280
+ userAgent,
1281
+ maskingPolicyVersion: masked.version
1282
+ });
1283
+ if (stillOverBudget) {
1284
+ throw new IssueStickerHeadlessError(
1285
+ "PAYLOAD_TOO_LARGE",
1286
+ "이슈 데이터가 전송 한도를 넘어 저장할 수 없습니다. 설명을 줄이거나 첨부를 빼고 다시 시도해주세요."
1287
+ );
1288
+ }
1289
+ const issue = await issueRepository.createIssue({ ...issueData, integrations });
1290
+ if (draft.sessionReplayEnabled && ((_c = sessionReplay.getMaskMemoSelectors()) == null ? void 0 : _c.length) === 0 && collectSnapshotSelectors(context.snapshot).length > 0) {
1291
+ sessionReplay.restartRecording();
1292
+ }
1293
+ return {
1294
+ issue,
1295
+ attachments,
1296
+ videoExcluded,
1297
+ screenshotExcluded: screen.screenshotExcluded !== null
1298
+ };
1299
+ }
1300
+ });
1301
+ } catch (error) {
1302
+ throw toHeadlessMaskingError(error, {
1303
+ onScreenRulesChanged,
1304
+ replayRecording: sessionReplay.isRecording(),
1305
+ recapturesScreenshots,
1306
+ restartReplay: () => sessionReplay.restartRecording()
1307
+ });
1308
+ }
1309
+ await issueRepository.syncDynamicFields(result.issue, draft, formSchema);
1310
+ return result;
1169
1311
  }
1170
1312
  const HEADLESS_CAPTURE_STYLE_ID = "issue-sticker-headless-capture-style";
1171
1313
  const HEADLESS_CAPTURE_STYLE = `
@@ -1272,6 +1414,13 @@ function toHeadlessAttachmentUpload(state) {
1272
1414
  progress: state.bytesTotal > 0 ? state.bytesSent / state.bytesTotal : 0
1273
1415
  };
1274
1416
  }
1417
+ function withExclusions(issue, flags) {
1418
+ return {
1419
+ ...issue,
1420
+ ...flags.videoExcluded ? { videoExcluded: true } : {},
1421
+ ...flags.screenshotExcluded ? { screenshotExcluded: true } : {}
1422
+ };
1423
+ }
1275
1424
  class HeadlessClientImpl {
1276
1425
  constructor(config) {
1277
1426
  var _a;
@@ -1282,6 +1431,12 @@ class HeadlessClientImpl {
1282
1431
  this.initializePromise = null;
1283
1432
  this.captureCleanup = null;
1284
1433
  this.captureSession = null;
1434
+ this.livePolicySource = {
1435
+ getMaskingPolicySnapshot: () => this.maskingPolicy.getMaskingPolicySnapshot(),
1436
+ refreshMaskingPolicySnapshot: (options) => this.maskingPolicy.refreshMaskingPolicySnapshot(options)
1437
+ };
1438
+ this.maskingPolicyPrefetch = null;
1439
+ this.maskingPolicyFetchedAt = null;
1285
1440
  this.suppressNextVideoStopSelection = false;
1286
1441
  this.destroyed = false;
1287
1442
  if (!((_a = config.apiKey) == null ? void 0 : _a.trim())) {
@@ -1291,12 +1446,13 @@ class HeadlessClientImpl {
1291
1446
  apiKey: config.apiKey,
1292
1447
  getTenantId: () => this.snapshot.auth.tenantId,
1293
1448
  getProjectId: () => this.snapshot.auth.projectId,
1294
- onToken: (result) => this.state.applyBridgeToken(result)
1449
+ onToken: (result) => this.applyBridgeToken(result)
1295
1450
  });
1296
1451
  this.transport = new HeadlessTransport({
1297
1452
  getAuthMode: () => this.snapshot.auth.mode,
1298
1453
  resolveAuthorizationHeader: (forceRefreshMemberToken) => this.resolveAuthorizationHeader(forceRefreshMemberToken)
1299
1454
  });
1455
+ this.maskingPolicy = this.createMaskingPolicy();
1300
1456
  this.issueRepository = new HeadlessIssueRepository({
1301
1457
  transport: this.transport,
1302
1458
  getProjectId: () => this.state.requireProjectId(),
@@ -1339,7 +1495,14 @@ class HeadlessClientImpl {
1339
1495
  });
1340
1496
  });
1341
1497
  this.sessionReplayController = new SessionReplayCaptureController({
1342
- loadCapabilities: () => this.request("/api/session-replays/capabilities")
1498
+ loadCapabilities: () => this.request("/api/session-replays/capabilities"),
1499
+ // 녹화는 초기화(initializeInternal)가 정책 요청을 띄운 뒤에 시작한다. 같은 요청을 기다린다.
1500
+ maskingPolicy: {
1501
+ source: this.livePolicySource,
1502
+ pendingRefresh: () => this.maskingPolicyPrefetch,
1503
+ refreshedWithin: (maxAgeMs) => this.maskingPolicyFetchedAt !== null && Date.now() - this.maskingPolicyFetchedAt <= maxAgeMs
1504
+ },
1505
+ reportInProgress: () => this.snapshot.draft !== null
1343
1506
  });
1344
1507
  this.sessionReplayController.subscribe((sessionReplay) => {
1345
1508
  this.applySessionReplaySnapshot(sessionReplay);
@@ -1511,19 +1674,24 @@ class HeadlessClientImpl {
1511
1674
  this.state.setSnapshot({ phase: "submittingIssue", error: null });
1512
1675
  try {
1513
1676
  const session = this.captureSession;
1514
- const screenshotPreviewUrls = (session == null ? void 0 : session.screenshots) ? await session.screenshots : this.state.requireDraft().screenshotPreviewUrls;
1677
+ const captured = (session == null ? void 0 : session.screenshots) ? await session.screenshots : NO_HEADLESS_SCREENSHOTS;
1515
1678
  const formSchema = this.snapshot.formSchema ?? ((session == null ? void 0 : session.formSchema) ? await session.formSchema : null);
1516
1679
  const submission = {
1517
1680
  ...this.state.requireDraft(),
1518
- screenshotPreviewUrls,
1519
1681
  title: input.title,
1520
1682
  description: input.description,
1521
1683
  intent: input.intent,
1522
1684
  dynamicFields: input.dynamicFields ?? {}
1523
1685
  };
1524
1686
  const videoFile = submission.mediaMode === "video" ? this.pendingMedia.videoFile : null;
1525
- const { issue: createdIssue, attachments } = await submitHeadlessIssueDraft({
1687
+ const {
1688
+ issue: createdIssue,
1689
+ attachments,
1690
+ videoExcluded,
1691
+ screenshotExcluded
1692
+ } = await submitHeadlessIssueDraft({
1526
1693
  draft: submission,
1694
+ target: (session == null ? void 0 : session.target) ?? null,
1527
1695
  pendingVideoFile: this.pendingMedia.videoFile,
1528
1696
  transport: this.transport,
1529
1697
  issueRepository: this.issueRepository,
@@ -1534,15 +1702,26 @@ class HeadlessClientImpl {
1534
1702
  logs: this.logCollectors.collect(),
1535
1703
  currentUrl: getCurrentPageUrl(),
1536
1704
  userAgent: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
1537
- takeSessionReplaySnapshot: () => this.takeSessionReplaySnapshot(),
1538
- getSocketConnections: () => this.logCollectors.getSocketConnections()
1705
+ screenshots: captured.screenshots,
1706
+ screenshotsDroppedByMasking: captured.maskingDropped,
1707
+ sessionReplay: this.submitReplay(),
1708
+ getSocketConnections: () => this.logCollectors.getSocketConnections(),
1709
+ maskingPolicy: this.livePolicySource,
1710
+ // 화면 규칙이 바뀌어 제출이 실패하면 불린다. 같은 대상을 다시 찍어 미리보기를 바꾸고
1711
+ // 리플레이 녹화를 새로 시작한다. 재시작은 거절하지 않는다.
1712
+ onScreenRulesChanged: () => {
1713
+ this.recaptureScreenshots();
1714
+ void this.sessionReplayController.restartRecording();
1715
+ },
1716
+ // 스크린샷 모드로 시작한 캡처만 다시 찍는다(recaptureScreenshots).
1717
+ recapturesScreenshots: Boolean(session == null ? void 0 : session.screenshots)
1539
1718
  });
1540
1719
  await enqueueIssueUploads({
1541
1720
  videoQueue: this.uploadQueues.video,
1542
1721
  attachmentQueue: this.uploadQueues.attachments,
1543
1722
  issueId: createdIssue.id,
1544
1723
  projectKey: this.state.requireProjectId(),
1545
- video: videoFile ? { blob: videoFile, mimeType: normalizeUploadMimeType(videoFile.type, "video") } : null,
1724
+ video: videoFile && !videoExcluded ? { blob: videoFile, mimeType: normalizeUploadMimeType(videoFile.type, "video") } : null,
1546
1725
  attachments
1547
1726
  });
1548
1727
  this.clearCaptureListeners();
@@ -1554,7 +1733,7 @@ class HeadlessClientImpl {
1554
1733
  formSchema: null,
1555
1734
  issues: [createdIssue, ...this.snapshot.issues]
1556
1735
  });
1557
- return createdIssue;
1736
+ return withExclusions(createdIssue, { videoExcluded, screenshotExcluded });
1558
1737
  } catch (error) {
1559
1738
  const nextError = this.toHeadlessError(error);
1560
1739
  this.state.setSnapshot({ phase: "draftingIssue", error: nextError });
@@ -1566,17 +1745,32 @@ class HeadlessClientImpl {
1566
1745
  }
1567
1746
  async createIssue(input) {
1568
1747
  var _a, _b;
1748
+ const onError = input == null ? void 0 : input.onError;
1749
+ let created = null;
1569
1750
  try {
1570
- if (!((_a = input.title) == null ? void 0 : _a.trim()) || !((_b = input.description) == null ? void 0 : _b.trim())) {
1571
- return this.skipCreateIssue("제목과 설명이 모두 필요합니다.");
1751
+ if (!((_a = input == null ? void 0 : input.title) == null ? void 0 : _a.trim()) || !((_b = input.description) == null ? void 0 : _b.trim())) {
1752
+ return this.skipCreateIssue(
1753
+ new IssueStickerHeadlessError("VALIDATION_ERROR", "제목과 설명이 모두 필요합니다."),
1754
+ onError
1755
+ );
1572
1756
  }
1573
1757
  if (typeof document === "undefined" || typeof window === "undefined") {
1574
- return this.skipCreateIssue("브라우저 DOM 에서만 이슈를 만들 수 있습니다.");
1758
+ return this.skipCreateIssue(
1759
+ new IssueStickerHeadlessError(
1760
+ "VALIDATION_ERROR",
1761
+ "브라우저 DOM 에서만 이슈를 만들 수 있습니다."
1762
+ ),
1763
+ onError
1764
+ );
1575
1765
  }
1576
1766
  await this.ensureInitialized();
1577
1767
  if (!this.autoIssueRateLimit.claim()) {
1578
1768
  return this.skipCreateIssue(
1579
- `${this.autoIssueRateLimit.label}까지 호출할 수 있습니다. 호출 조건을 다시 확인해주세요.`
1769
+ new IssueStickerHeadlessError(
1770
+ "RATE_LIMITED",
1771
+ `${this.autoIssueRateLimit.label}까지 호출할 수 있습니다. 호출 조건을 다시 확인해주세요.`
1772
+ ),
1773
+ onError
1580
1774
  );
1581
1775
  }
1582
1776
  const target = input.target ?? document.body;
@@ -1586,18 +1780,21 @@ class HeadlessClientImpl {
1586
1780
  sessionReplayEnabled: Boolean(sessionReplay.enabled && sessionReplay.canAttach)
1587
1781
  });
1588
1782
  const logs = this.logCollectors.collect();
1589
- const screenshotPreviewUrls = input.media === "none" ? [] : await this.logCollectors.pauseNetworkWhile(
1590
- () => captureHeadlessScreenshots({ target, excludeSelectors: this.getExcludeSelectors() })
1591
- );
1783
+ const captured = input.media === "none" ? NO_HEADLESS_SCREENSHOTS : await this.captureScreenshots(target, await this.freshCaptureSelectors());
1592
1784
  const formSchema = this.snapshot.auth.mode === "member" && Object.keys(input.dynamicFields ?? {}).length > 0 ? await this.loadFormSchema({}) : null;
1593
- const { issue: createdIssue, attachments } = await submitHeadlessIssueDraft({
1785
+ const {
1786
+ issue: createdIssue,
1787
+ attachments,
1788
+ screenshotExcluded
1789
+ } = await submitHeadlessIssueDraft({
1594
1790
  draft: {
1595
1791
  ...draft,
1596
- screenshotPreviewUrls,
1792
+ screenshotPreviewUrls: [],
1597
1793
  title: input.title,
1598
1794
  description: input.description,
1599
1795
  dynamicFields: input.dynamicFields ?? {}
1600
1796
  },
1797
+ target,
1601
1798
  pendingVideoFile: null,
1602
1799
  transport: this.transport,
1603
1800
  issueRepository: this.issueRepository,
@@ -1608,9 +1805,16 @@ class HeadlessClientImpl {
1608
1805
  logs,
1609
1806
  currentUrl: getCurrentPageUrl(),
1610
1807
  userAgent: typeof navigator !== "undefined" ? navigator.userAgent : void 0,
1611
- takeSessionReplaySnapshot: () => this.takeSessionReplaySnapshot(),
1612
- getSocketConnections: () => this.logCollectors.getSocketConnections()
1808
+ screenshots: captured.screenshots,
1809
+ screenshotsDroppedByMasking: captured.maskingDropped,
1810
+ sessionReplay: this.submitReplay(),
1811
+ getSocketConnections: () => this.logCollectors.getSocketConnections(),
1812
+ maskingPolicy: this.livePolicySource,
1813
+ // 미리보기가 없어 다시 찍지 않는다. 다음 호출이 새 규칙을 덮도록 리플레이 녹화만 새로 시작한다.
1814
+ onScreenRulesChanged: () => void this.sessionReplayController.restartRecording(),
1815
+ recapturesScreenshots: false
1613
1816
  });
1817
+ created = withExclusions(createdIssue, { screenshotExcluded });
1614
1818
  await enqueueIssueUploads({
1615
1819
  videoQueue: this.uploadQueues.video,
1616
1820
  attachmentQueue: this.uploadQueues.attachments,
@@ -1622,22 +1826,51 @@ class HeadlessClientImpl {
1622
1826
  this.state.setSnapshot({
1623
1827
  issues: [createdIssue, ...this.snapshot.issues]
1624
1828
  });
1625
- return createdIssue;
1829
+ return created;
1626
1830
  } catch (error) {
1627
- return this.skipCreateIssue("이슈를 생성하지 못했습니다.", error);
1831
+ if (created) {
1832
+ console.warn(
1833
+ ISSUE_STICKER_INTERNAL_LOG_PREFIX,
1834
+ "[headless] createIssue: 이슈를 생성한 뒤 후속 처리에 실패했습니다.",
1835
+ error
1836
+ );
1837
+ return created;
1838
+ }
1839
+ const replay = this.submitReplay();
1840
+ const mapped = toHeadlessMaskingError(error, {
1841
+ onScreenRulesChanged: () => replay.restartRecording(),
1842
+ replayRecording: replay.isRecording(),
1843
+ recapturesScreenshots: false,
1844
+ restartReplay: () => replay.restartRecording()
1845
+ });
1846
+ return this.skipCreateIssue(this.toHeadlessError(mapped), onError);
1628
1847
  }
1629
1848
  }
1630
1849
  /**
1631
- * createIssue 가 이슈를 만들지 않고 끝난 이유를 남긴다.
1850
+ * createIssue 가 이슈를 만들지 않고 끝난 이유를 남기고 onError 로 넘긴다.
1632
1851
  *
1633
1852
  * devLogger 는 프로덕션 빌드에서 침묵하는데, 이 실패는 예외로 드러나지 않으므로
1634
1853
  * 통합한 쪽이 프로덕션에서도 알아야 한다. 접두어가 붙어 있어 우리 수집기가
1635
1854
  * 자기 로그로 걸러내므로 고객 이슈의 콘솔 로그를 오염시키지 않는다.
1855
+ * onError 가 던져도 createIssue 는 던지지 않는다 — 전역 핸들러 안에서 부른 경우 루프가 된다.
1636
1856
  */
1637
- skipCreateIssue(reason, cause) {
1638
- const args = [ISSUE_STICKER_INTERNAL_LOG_PREFIX, "[headless] createIssue:", reason];
1639
- if (cause !== void 0) args.push(cause);
1857
+ skipCreateIssue(error, onError) {
1858
+ const args = [
1859
+ ISSUE_STICKER_INTERNAL_LOG_PREFIX,
1860
+ "[headless] createIssue:",
1861
+ error.message
1862
+ ];
1863
+ if (error.cause !== void 0) args.push(error.cause);
1640
1864
  console.warn(...args);
1865
+ try {
1866
+ onError == null ? void 0 : onError(error);
1867
+ } catch (callbackError) {
1868
+ console.warn(
1869
+ ISSUE_STICKER_INTERNAL_LOG_PREFIX,
1870
+ "[headless] createIssue onError:",
1871
+ callbackError
1872
+ );
1873
+ }
1641
1874
  return null;
1642
1875
  }
1643
1876
  async loginAsMember() {
@@ -1682,8 +1915,35 @@ class HeadlessClientImpl {
1682
1915
  this.logCollectors.stop();
1683
1916
  this.autoIssueRateLimit.reset();
1684
1917
  this.authBridge.destroy();
1918
+ this.maskingPolicy = this.createMaskingPolicy();
1919
+ this.maskingPolicyPrefetch = null;
1920
+ this.maskingPolicyFetchedAt = null;
1685
1921
  this.state.reset();
1686
1922
  }
1923
+ createMaskingPolicy() {
1924
+ const inner = createMaskingPolicySource({
1925
+ // 제출 직전 재확인이 브라우저 캐시의 옛 버전을 받으면 409 를 부른다.
1926
+ fetchSnapshot: () => this.transport.request("/api/sdk/masking-policy", { cache: "no-store" }),
1927
+ cache: createMemoryMaskingPolicyCache()
1928
+ });
1929
+ const source = {
1930
+ getMaskingPolicySnapshot: () => inner.getMaskingPolicySnapshot(),
1931
+ refreshMaskingPolicySnapshot: async (options) => {
1932
+ const snapshot = await inner.refreshMaskingPolicySnapshot(options);
1933
+ if (this.maskingPolicy === source) this.maskingPolicyFetchedAt = Date.now();
1934
+ return snapshot;
1935
+ }
1936
+ };
1937
+ return source;
1938
+ }
1939
+ /** 정책을 미리 받는다. 녹화 시작은 캐시가 없으면 이 요청을 기다린다. */
1940
+ prefetchMaskingPolicy() {
1941
+ const prefetch = this.maskingPolicy.refreshMaskingPolicySnapshot({
1942
+ timeoutMs: MASKING_POLICY_PREFETCH_TIMEOUT_MS
1943
+ });
1944
+ prefetch.catch(() => void 0);
1945
+ this.maskingPolicyPrefetch = prefetch;
1946
+ }
1687
1947
  async initializeInternal() {
1688
1948
  this.state.setSnapshot({ phase: "initializing", error: null });
1689
1949
  try {
@@ -1706,6 +1966,7 @@ class HeadlessClientImpl {
1706
1966
  filterSchema: { groups: [], unavailableReason: "public-mode" }
1707
1967
  });
1708
1968
  this.logCollectors.start();
1969
+ this.prefetchMaskingPolicy();
1709
1970
  const { video, attachments } = this.uploadQueues;
1710
1971
  void this.uploadQueues.resume([result.projectId]).then(() => {
1711
1972
  void video.pruneExpired();
@@ -1723,25 +1984,62 @@ class HeadlessClientImpl {
1723
1984
  const sessionReplay = this.sessionReplayController.getSnapshot();
1724
1985
  if (!sessionReplay.enabled || !sessionReplay.canAttach) return null;
1725
1986
  try {
1726
- const snapshot = await this.sessionReplayController.takeSnapshot();
1987
+ const snapshot = await this.sessionReplayController.takeSnapshot([]);
1727
1988
  return snapshot ? toSessionReplayMeta(snapshot) : null;
1728
1989
  } catch {
1729
1990
  return null;
1730
1991
  }
1731
1992
  }
1732
- async takeSessionReplaySnapshot() {
1733
- const sessionReplay = this.sessionReplayController.getSnapshot();
1734
- if (!sessionReplay.enabled || !sessionReplay.canAttach) return null;
1735
- return this.sessionReplayController.takeSnapshot().catch(() => null);
1993
+ /**
1994
+ * 제출이 사용하는 리플레이 녹화. 꺼졌거나 한도를 넘으면 메모와 스냅샷이 null 이다. 스냅샷의
1995
+ * 마스킹 실패는 삼키지 않는다 — submitDraft 가 이슈 생성을 멈추고 CAPTURE_FAILED 로 바꾼다.
1996
+ */
1997
+ submitReplay() {
1998
+ const controller = this.sessionReplayController;
1999
+ const attached = () => {
2000
+ const sessionReplay = controller.getSnapshot();
2001
+ return sessionReplay.enabled && sessionReplay.canAttach;
2002
+ };
2003
+ return {
2004
+ whenRestarted: () => controller.whenRestarted(),
2005
+ getMaskMemoSelectors: () => attached() ? controller.getMaskMemoSelectors() : null,
2006
+ takeSnapshot: async (maskSelectors) => attached() ? controller.takeSnapshot(maskSelectors) : null,
2007
+ isRecording: () => controller.isRecording(),
2008
+ restartRecording: () => void controller.restartRecording()
2009
+ };
2010
+ }
2011
+ /**
2012
+ * 다른 워크스페이스·프로젝트 컨텍스트로 바뀌면 정책을 새로 받고, 그 정책 선택자로 리플레이 녹화를
2013
+ * 새로 시작한다.
2014
+ */
2015
+ applyBridgeToken(result) {
2016
+ const { tenantId, projectId } = this.snapshot.auth;
2017
+ this.state.applyBridgeToken(result);
2018
+ const next = this.snapshot.auth;
2019
+ if (tenantId !== null && (next.tenantId !== tenantId || next.projectId !== projectId)) {
2020
+ this.maskingPolicy = this.createMaskingPolicy();
2021
+ this.maskingPolicyFetchedAt = null;
2022
+ this.prefetchMaskingPolicy();
2023
+ void this.sessionReplayController.restartRecording();
2024
+ }
1736
2025
  }
1737
- /** 스티커 목록에는 영상 URL 이 없다. 업로드 완료 때 받아 둔 URL 을 같은 이슈에 남긴다. */
2026
+ /**
2027
+ * 스티커 목록에는 영상 URL 도 업로드를 포기한 까닭도 없다. 업로드가 끝날 때 받아 둔 값을 같은
2028
+ * 이슈에 남긴다. 까닭은 여전히 실패 상태일 때만 남긴다.
2029
+ */
1738
2030
  keepKnownVideos(issues) {
1739
- const known = new Map(
1740
- this.snapshot.issues.flatMap((issue) => issue.video ? [[issue.id, issue.video]] : [])
1741
- );
1742
- return issues.map(
1743
- (issue) => !issue.video && known.has(issue.id) ? { ...issue, video: known.get(issue.id) } : issue
1744
- );
2031
+ const known = new Map(this.snapshot.issues.map((issue) => [issue.id, issue]));
2032
+ return issues.map((issue) => {
2033
+ const previous = known.get(issue.id);
2034
+ const video = !issue.video ? previous == null ? void 0 : previous.video : void 0;
2035
+ const reason = issue.video_status === "failed" ? previous == null ? void 0 : previous.video_failure_reason : void 0;
2036
+ if (!video && !reason) return issue;
2037
+ return {
2038
+ ...issue,
2039
+ ...video ? { video } : {},
2040
+ ...reason ? { video_failure_reason: reason } : {}
2041
+ };
2042
+ });
1745
2043
  }
1746
2044
  /**
1747
2045
  * 업로드가 끝나면 목록·상세의 같은 이슈에 영상 상태를 반영한다. 완료는 재조회해 영상 URL 까지
@@ -1752,12 +2050,18 @@ class HeadlessClientImpl {
1752
2050
  const refreshed = event.type === "completed" ? await this.issueRepository.openIssue(event.issueId).catch(() => null) : null;
1753
2051
  if (this.destroyed) return;
1754
2052
  const videoStatus = event.type === "completed" ? "ready" : "failed";
2053
+ const failure = event.reason ? { video_failure_reason: event.reason } : {};
1755
2054
  const { issues, activeIssue } = this.snapshot;
1756
2055
  this.state.setSnapshot({
1757
2056
  issues: issues.map(
1758
- (issue) => issue.id === event.issueId ? { ...issue, video: (refreshed == null ? void 0 : refreshed.video) ?? issue.video, video_status: videoStatus } : issue
2057
+ (issue) => issue.id === event.issueId ? {
2058
+ ...issue,
2059
+ video: (refreshed == null ? void 0 : refreshed.video) ?? issue.video,
2060
+ video_status: videoStatus,
2061
+ ...failure
2062
+ } : issue
1759
2063
  ),
1760
- activeIssue: (activeIssue == null ? void 0 : activeIssue.id) === event.issueId ? refreshed ?? { ...activeIssue, video_status: videoStatus } : activeIssue
2064
+ activeIssue: (activeIssue == null ? void 0 : activeIssue.id) === event.issueId ? refreshed ?? { ...activeIssue, video_status: videoStatus, ...failure } : activeIssue
1761
2065
  });
1762
2066
  }
1763
2067
  /** 리플레이가 연결되거나 포기되면 목록·상세의 같은 이슈에 상태를 반영한다. 소켓 전사는 상태가 없다. */
@@ -1840,15 +2144,16 @@ class HeadlessClientImpl {
1840
2144
  this.beginBackgroundCapturePreparation(draft, target);
1841
2145
  }
1842
2146
  beginBackgroundCapturePreparation(draft, target) {
1843
- var _a, _b;
2147
+ var _a;
1844
2148
  const token = {};
1845
2149
  this.captureSession = {
1846
2150
  token,
2151
+ target,
1847
2152
  screenshots: draft.mediaMode === "screenshot" ? (
1848
- // 스크린샷은 웹폰트·이미지를 fetch 로 인라인한다. 그 사이 수집을 멈추지 않으면
1849
- // 최근 50건이 그 요청들로 채워져 진짜 앱 요청이 밀려난다.
1850
- this.logCollectors.pauseNetworkWhile(
1851
- () => captureHeadlessScreenshots({ target, excludeSelectors: this.getExcludeSelectors() })
2153
+ // 영역은 초기화 때 받아 둔 정책 캐시의 선택자로 계산한다. 캐시가 없으면 빈 목록이고,
2154
+ // 미리보기를 칠할 때 덮개 검사에 걸려 한 번 다시 찍는다.
2155
+ loadCaptureSelectors(() => this.livePolicySource.getMaskingPolicySnapshot()).then(
2156
+ (selectors) => this.captureScreenshots(target, selectors ?? [])
1852
2157
  )
1853
2158
  ) : null,
1854
2159
  formSchema: this.loadFormSchema({})
@@ -1857,11 +2162,10 @@ class HeadlessClientImpl {
1857
2162
  var _a2;
1858
2163
  return ((_a2 = this.captureSession) == null ? void 0 : _a2.token) === token;
1859
2164
  };
1860
- void ((_a = this.captureSession.screenshots) == null ? void 0 : _a.then((screenshotPreviewUrls) => {
1861
- if (!isCurrentSession()) return;
1862
- this.mergeDraft({ screenshotPreviewUrls });
1863
- }));
1864
- void ((_b = this.captureSession.formSchema) == null ? void 0 : _b.then((formSchema) => {
2165
+ if (this.captureSession.screenshots) {
2166
+ this.publishPreview(this.captureSession.screenshots, true);
2167
+ }
2168
+ void ((_a = this.captureSession.formSchema) == null ? void 0 : _a.then((formSchema) => {
1865
2169
  if (!isCurrentSession()) return;
1866
2170
  this.state.setSnapshot({ formSchema });
1867
2171
  }));
@@ -1874,6 +2178,64 @@ class HeadlessClientImpl {
1874
2178
  });
1875
2179
  }
1876
2180
  }
2181
+ /**
2182
+ * 화면 규칙이 바뀌어 지금 원본을 쓸 수 없다. 같은 대상을 다시 찍어 미리보기를 바꾼다.
2183
+ *
2184
+ * 선택자는 캐시가 아니라 제출과 같은 방식으로 다시 받아 읽는다. 받은 스냅샷의 캐시 쓰기는
2185
+ * 기다리지 않고 떠나므로 캐시만 읽으면 바뀌기 전 선택자로 찍을 수 있다. 정책을 받지 못하면
2186
+ * 스크린샷 없이 두고, 마스킹 정책 때문에 버렸다고 표시해 제출 때 알린다.
2187
+ */
2188
+ recaptureScreenshots(autoRecapture = true) {
2189
+ const session = this.captureSession;
2190
+ if (!(session == null ? void 0 : session.screenshots)) return;
2191
+ const screenshots = this.freshCaptureSelectors().then(
2192
+ (selectors) => this.captureScreenshots(session.target, selectors),
2193
+ () => ({ screenshots: [], maskingDropped: true })
2194
+ );
2195
+ this.captureSession = { ...session, screenshots };
2196
+ this.mergeDraft({ screenshotPreviewUrls: [] });
2197
+ this.publishPreview(screenshots, autoRecapture);
2198
+ }
2199
+ /**
2200
+ * 원본이 준비되면 칠한 미리보기를 draft 에 싣는다. 칠하지 못하면 원본을 내보내지 않고 비워 둔다.
2201
+ *
2202
+ * 덮개 검사에 걸리면(캐시 없이 빈 선택자로 찍었거나 그 사이 정책이 바뀌었다) 그대로 두면 첫 제출이
2203
+ * 반드시 실패하므로 같은 대상을 한 번 다시 찍는다. 다시 찍은 캡처는 또 다시 찍지 않는다.
2204
+ */
2205
+ publishPreview(screenshots, autoRecapture) {
2206
+ const isCurrent = () => {
2207
+ var _a;
2208
+ return ((_a = this.captureSession) == null ? void 0 : _a.screenshots) === screenshots;
2209
+ };
2210
+ void screenshots.then((captured) => this.previewScreenshots(captured.screenshots)).then(
2211
+ (screenshotPreviewUrls) => {
2212
+ if (isCurrent()) this.mergeDraft({ screenshotPreviewUrls });
2213
+ },
2214
+ (error) => {
2215
+ if (isCurrent() && autoRecapture && error instanceof MaskingScreenRulesChangedError) {
2216
+ this.recaptureScreenshots(false);
2217
+ }
2218
+ }
2219
+ );
2220
+ }
2221
+ /** 제출과 같은 방식(짧게 다시 받고 실패하면 캐시)으로 읽은 정책 선택자. 둘 다 없으면 실패한다. */
2222
+ async freshCaptureSelectors() {
2223
+ return collectSnapshotSelectors(await loadMaskingPolicyForSubmit(this.livePolicySource));
2224
+ }
2225
+ captureScreenshots(target, maskSelectors) {
2226
+ return this.logCollectors.pauseNetworkWhile(
2227
+ () => captureHeadlessScreenshots({
2228
+ target,
2229
+ excludeSelectors: this.getExcludeSelectors(),
2230
+ maskSelectors
2231
+ })
2232
+ );
2233
+ }
2234
+ async previewScreenshots(shots) {
2235
+ const snapshot = await loadMaskingPolicyForSubmit(this.livePolicySource);
2236
+ const rules = resolveEffectivePolicy(snapshot, this.state.requireProjectId());
2237
+ return maskScreenshotsForSubmit(shots, rules);
2238
+ }
1877
2239
  mergeDraft(patch) {
1878
2240
  const draft = this.snapshot.draft;
1879
2241
  if (!draft) return;