@skyramp/skyramp 1.3.10 → 1.3.12
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/package.json +7 -5
- package/scripts/build-pdf-bundle.js +141 -0
- package/scripts/pdf-execution-helpers.js +340 -0
- package/src/classes/MockV2.d.ts +26 -22
- package/src/classes/MockV2.js +51 -20
- package/src/classes/SkyrampClient.d.ts +10 -9
- package/src/classes/SkyrampClient.js +122 -20
- package/src/classes/SmartPlaywright.js +418 -2
- package/src/function.d.ts +14 -0
- package/src/function.js +146 -1
- package/src/index.js +4 -2
- package/src/pdfViewer/bundle.d.ts +11 -0
- package/src/pdfViewer/bundle.js +1349 -0
- package/src/pdfViewer/index.d.ts +8 -0
- package/src/pdfViewer/index.js +14 -0
- package/src/pdfViewer/validator.d.ts +25 -0
- package/src/pdfViewer/validator.js +119 -0
- package/src/utils.d.ts +8 -0
- package/src/utils.js +5 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
/* global window */
|
|
1
2
|
const { expect: playwrightExpect } = require('@playwright/test');
|
|
2
3
|
const lib = require('../lib');
|
|
3
4
|
const koffi = require('koffi');
|
|
4
5
|
const fs = require('fs');
|
|
5
6
|
const path = require('path');
|
|
7
|
+
const { PDF_VIEWER_INJECTION_SCRIPT } = require('../pdfViewer');
|
|
6
8
|
|
|
7
9
|
const responseType = koffi.struct({
|
|
8
10
|
response: 'char*',
|
|
@@ -722,6 +724,11 @@ class SkyrampPlaywrightLocator {
|
|
|
722
724
|
return this._skyrampPage.newSkyrampPlaywrightLocator(new_locator, null, null);
|
|
723
725
|
}
|
|
724
726
|
|
|
727
|
+
contentFrame() {
|
|
728
|
+
const frameLocator = this._locator.contentFrame();
|
|
729
|
+
return new SkyrampPlaywrightFrameLocator(this._skyrampPage, frameLocator);
|
|
730
|
+
}
|
|
731
|
+
|
|
725
732
|
page() {
|
|
726
733
|
return this._skyrampPage._page;
|
|
727
734
|
}
|
|
@@ -732,6 +739,91 @@ class SkyrampPlaywrightLocator {
|
|
|
732
739
|
}
|
|
733
740
|
}
|
|
734
741
|
|
|
742
|
+
class SkyrampPlaywrightFrameLocator {
|
|
743
|
+
constructor(skyrampPage, frameLocator) {
|
|
744
|
+
this._skyrampPage = skyrampPage
|
|
745
|
+
this._frameLocator = frameLocator
|
|
746
|
+
debug(`SkyrampPlaywrightFrameLocator instantiated for ${frameLocator}`)
|
|
747
|
+
return new Proxy(this, {
|
|
748
|
+
get(wrapper, prop, receiver) {
|
|
749
|
+
if (Reflect.has(wrapper, prop)) {
|
|
750
|
+
return Reflect.get(wrapper, prop, receiver);
|
|
751
|
+
}
|
|
752
|
+
const value = Reflect.get(wrapper._frameLocator, prop, wrapper._frameLocator);
|
|
753
|
+
if (typeof value === 'function') {
|
|
754
|
+
return value.bind(wrapper._frameLocator);
|
|
755
|
+
}
|
|
756
|
+
return value;
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
locator(selector, options) {
|
|
762
|
+
const originalLocator = this._frameLocator.locator(selector, options);
|
|
763
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, selector, options);
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
getByRole(role, options) {
|
|
767
|
+
const originalLocator = this._frameLocator.getByRole(role, options);
|
|
768
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, role, options);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
getByText(text, options) {
|
|
772
|
+
const originalLocator = this._frameLocator.getByText(text, options);
|
|
773
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, text, options);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
getByLabel(label, options) {
|
|
777
|
+
const originalLocator = this._frameLocator.getByLabel(label, options);
|
|
778
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, label, options);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
getByTestId(testId, options) {
|
|
782
|
+
const originalLocator = this._frameLocator.getByTestId(testId);
|
|
783
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, testId, options);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
getByTitle(title, options) {
|
|
787
|
+
const originalLocator = this._frameLocator.getByTitle(title, options);
|
|
788
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, title, options);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
getByPlaceholder(placeholder, options) {
|
|
792
|
+
const originalLocator = this._frameLocator.getByPlaceholder(placeholder, options);
|
|
793
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, placeholder, options);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
getByAltText(alt, options) {
|
|
797
|
+
const originalLocator = this._frameLocator.getByAltText(alt, options);
|
|
798
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, alt, options);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
owner() {
|
|
802
|
+
const originalLocator = this._frameLocator.owner();
|
|
803
|
+
return this._skyrampPage.newSkyrampPlaywrightLocator(originalLocator, null, null);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
frameLocator(selector) {
|
|
807
|
+
const originalFrameLocator = this._frameLocator.frameLocator(selector);
|
|
808
|
+
return new SkyrampPlaywrightFrameLocator(this._skyrampPage, originalFrameLocator);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
nth(index) {
|
|
812
|
+
const originalFrameLocator = this._frameLocator.nth(index);
|
|
813
|
+
return new SkyrampPlaywrightFrameLocator(this._skyrampPage, originalFrameLocator);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
first() {
|
|
817
|
+
const originalFrameLocator = this._frameLocator.first();
|
|
818
|
+
return new SkyrampPlaywrightFrameLocator(this._skyrampPage, originalFrameLocator);
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
last() {
|
|
822
|
+
const originalFrameLocator = this._frameLocator.last();
|
|
823
|
+
return new SkyrampPlaywrightFrameLocator(this._skyrampPage, originalFrameLocator);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
735
827
|
class SkyrampPlaywrightPage {
|
|
736
828
|
constructor(page, testInfo) {
|
|
737
829
|
checkForUpdate("npm").catch((error) => {
|
|
@@ -740,6 +832,14 @@ class SkyrampPlaywrightPage {
|
|
|
740
832
|
|
|
741
833
|
this._page = page;
|
|
742
834
|
this._testInfo = testInfo; // Store testInfo for screenshot auto-baseline
|
|
835
|
+
|
|
836
|
+
// Setup PDF viewer injection (store promise to await before navigation)
|
|
837
|
+
this._pdfSetupPromise = this._setupPdfInjection().catch(err => {
|
|
838
|
+
debug('[SmartPlaywright] PDF setup failed:', err);
|
|
839
|
+
// Return resolved promise to allow test to continue even if setup fails
|
|
840
|
+
return Promise.resolve();
|
|
841
|
+
});
|
|
842
|
+
|
|
743
843
|
return new Proxy(this, {
|
|
744
844
|
// The `get` trap is the key to forwarding.
|
|
745
845
|
// This will foraward any methods not implemented in this struct
|
|
@@ -768,6 +868,280 @@ class SkyrampPlaywrightPage {
|
|
|
768
868
|
return this._page;
|
|
769
869
|
}
|
|
770
870
|
|
|
871
|
+
/**
|
|
872
|
+
* Sets up PDF viewer injection for this page and all future popups
|
|
873
|
+
* Called automatically from constructor
|
|
874
|
+
*/
|
|
875
|
+
async _setupPdfInjection() {
|
|
876
|
+
try {
|
|
877
|
+
// Check if PDF injection is already set up for this context (prevent duplicate setup)
|
|
878
|
+
const context = this._page.context();
|
|
879
|
+
if (context.__pw_pdfInjectionSetup) {
|
|
880
|
+
debug('[SmartPlaywright] PDF injection already set up for this context, skipping');
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
debug('[SmartPlaywright] Setting up PDF viewer injection...');
|
|
885
|
+
|
|
886
|
+
// 1. Add init script at CONTEXT level (applies to ALL pages including popups)
|
|
887
|
+
await context.addInitScript(PDF_VIEWER_INJECTION_SCRIPT);
|
|
888
|
+
|
|
889
|
+
// 2. Set up route interception for PDFs at CONTEXT level
|
|
890
|
+
// This ensures it applies to all pages (including popups) immediately
|
|
891
|
+
await context.route('**/*', async (route) => {
|
|
892
|
+
const url = route.request().url();
|
|
893
|
+
const resourceType = route.request().resourceType();
|
|
894
|
+
|
|
895
|
+
// Check if this is a PDF URL (must contain .pdf in the URL)
|
|
896
|
+
const isPdfUrl = url.toLowerCase().includes('.pdf');
|
|
897
|
+
|
|
898
|
+
// Debug logging
|
|
899
|
+
if (isPdfUrl) {
|
|
900
|
+
debug(`[SmartPlaywright] PDF URL detected: ${url.substring(0, 100)}`);
|
|
901
|
+
debug(`[SmartPlaywright] Resource type: ${resourceType}`);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (!isPdfUrl) {
|
|
905
|
+
// Not a PDF, continue normally
|
|
906
|
+
await route.continue();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Handle PDF document navigation (direct navigation to PDF URL)
|
|
911
|
+
if (resourceType === 'document') {
|
|
912
|
+
debug('[SmartPlaywright] Intercepting PDF navigation:', url);
|
|
913
|
+
|
|
914
|
+
try {
|
|
915
|
+
// Fetch the PDF via Playwright's context (bypasses CORS)
|
|
916
|
+
const response = await this._page.context().request.fetch(url);
|
|
917
|
+
const pdfBuffer = await response.body();
|
|
918
|
+
const base64Pdf = pdfBuffer.toString('base64');
|
|
919
|
+
const dataUrl = `data:application/pdf;base64,${base64Pdf}`;
|
|
920
|
+
|
|
921
|
+
// Extract filename from URL
|
|
922
|
+
let filename = 'Document.pdf';
|
|
923
|
+
try {
|
|
924
|
+
const urlObj = new URL(url);
|
|
925
|
+
const pathname = urlObj.pathname;
|
|
926
|
+
const lastSlash = pathname.lastIndexOf('/');
|
|
927
|
+
if (lastSlash !== -1) {
|
|
928
|
+
filename = pathname.substring(lastSlash + 1);
|
|
929
|
+
filename = decodeURIComponent(filename);
|
|
930
|
+
}
|
|
931
|
+
} catch (e) {
|
|
932
|
+
// Use default filename
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Create HTML page with PDF.js viewer
|
|
936
|
+
const html = `<!DOCTYPE html>
|
|
937
|
+
<html>
|
|
938
|
+
<head>
|
|
939
|
+
<meta charset="UTF-8">
|
|
940
|
+
<title>${filename}</title>
|
|
941
|
+
<style>
|
|
942
|
+
body, html {
|
|
943
|
+
margin: 0;
|
|
944
|
+
padding: 0;
|
|
945
|
+
width: 100%;
|
|
946
|
+
height: 100%;
|
|
947
|
+
overflow: hidden;
|
|
948
|
+
}
|
|
949
|
+
</style>
|
|
950
|
+
</head>
|
|
951
|
+
<body>
|
|
952
|
+
<div id="pw-pdf-viewer-container"></div>
|
|
953
|
+
<script>
|
|
954
|
+
// Wait for init script to load PdfJsViewer class, then render PDF directly
|
|
955
|
+
(function checkAndRender() {
|
|
956
|
+
// Check if PdfJsViewer class is available (from init script)
|
|
957
|
+
if (typeof PdfJsViewer !== 'undefined') {
|
|
958
|
+
console.log('[PW-PDF] Init script loaded, rendering PDF...');
|
|
959
|
+
|
|
960
|
+
const container = document.getElementById('pw-pdf-viewer-container');
|
|
961
|
+
container.setAttribute('data-pw-pdf-viewer', 'true');
|
|
962
|
+
container.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #525252;';
|
|
963
|
+
|
|
964
|
+
// Create PDF.js viewer instance and render
|
|
965
|
+
const viewer = new PdfJsViewer(container);
|
|
966
|
+
viewer.renderPdf({
|
|
967
|
+
pdfDataUrl: '${dataUrl}',
|
|
968
|
+
filename: '${filename}',
|
|
969
|
+
onReady: () => {
|
|
970
|
+
console.log('[PW-PDF] ✅ PDF rendered successfully');
|
|
971
|
+
},
|
|
972
|
+
onError: (error) => {
|
|
973
|
+
console.error('[PW-PDF] ❌ Failed to render PDF:', error);
|
|
974
|
+
}
|
|
975
|
+
});
|
|
976
|
+
} else {
|
|
977
|
+
// PdfJsViewer not loaded yet, wait and retry
|
|
978
|
+
setTimeout(checkAndRender, 50);
|
|
979
|
+
}
|
|
980
|
+
})();
|
|
981
|
+
</script>
|
|
982
|
+
</body>
|
|
983
|
+
</html>`;
|
|
984
|
+
|
|
985
|
+
await route.fulfill({
|
|
986
|
+
status: 200,
|
|
987
|
+
headers: { 'Content-Type': 'text/html' },
|
|
988
|
+
body: html
|
|
989
|
+
});
|
|
990
|
+
} catch (error) {
|
|
991
|
+
debug('[SmartPlaywright] PDF navigation interception failed:', error.message);
|
|
992
|
+
await route.abort('failed');
|
|
993
|
+
}
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// Handle PDF fetch/XHR requests (for embedded PDFs or downloads)
|
|
998
|
+
if (resourceType === 'fetch' || resourceType === 'xhr') {
|
|
999
|
+
debug('[SmartPlaywright] Intercepting PDF fetch:', url);
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
// Fetch via Playwright's context (bypasses CORS)
|
|
1003
|
+
const response = await this._page.context().request.fetch(url);
|
|
1004
|
+
const buffer = await response.body();
|
|
1005
|
+
|
|
1006
|
+
// Store the PDF URL and data for potential blob URL handling
|
|
1007
|
+
await this._page.evaluate((pdfUrl) => {
|
|
1008
|
+
window.__pw_lastPdfUrl = pdfUrl;
|
|
1009
|
+
}, url).catch(() => {});
|
|
1010
|
+
|
|
1011
|
+
await route.fulfill({
|
|
1012
|
+
status: 200,
|
|
1013
|
+
headers: { 'Content-Type': 'application/pdf' },
|
|
1014
|
+
body: buffer
|
|
1015
|
+
});
|
|
1016
|
+
} catch (error) {
|
|
1017
|
+
debug('[SmartPlaywright] PDF fetch failed:', error.message);
|
|
1018
|
+
await route.abort('failed');
|
|
1019
|
+
}
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
// Intercept blob: URLs (likely created after PDF fetch)
|
|
1024
|
+
if (url.startsWith('blob:') && resourceType === 'document') {
|
|
1025
|
+
debug('[SmartPlaywright] Intercepting blob URL navigation (likely PDF)');
|
|
1026
|
+
|
|
1027
|
+
// Get the last PDF URL that was fetched
|
|
1028
|
+
const lastPdfUrl = await this._page.evaluate(() => window.__pw_lastPdfUrl).catch(() => null);
|
|
1029
|
+
|
|
1030
|
+
if (lastPdfUrl) {
|
|
1031
|
+
debug('[SmartPlaywright] Blob URL is for PDF, redirecting to HTML viewer');
|
|
1032
|
+
|
|
1033
|
+
try {
|
|
1034
|
+
// Fetch the original PDF
|
|
1035
|
+
const response = await this._page.context().request.fetch(lastPdfUrl);
|
|
1036
|
+
const pdfBuffer = await response.body();
|
|
1037
|
+
const base64Pdf = pdfBuffer.toString('base64');
|
|
1038
|
+
const dataUrl = `data:application/pdf;base64,${base64Pdf}`;
|
|
1039
|
+
|
|
1040
|
+
let filename = 'Document.pdf';
|
|
1041
|
+
try {
|
|
1042
|
+
const urlObj = new URL(lastPdfUrl);
|
|
1043
|
+
const pathname = urlObj.pathname;
|
|
1044
|
+
const lastSlash = pathname.lastIndexOf('/');
|
|
1045
|
+
if (lastSlash !== -1) {
|
|
1046
|
+
filename = pathname.substring(lastSlash + 1);
|
|
1047
|
+
filename = decodeURIComponent(filename);
|
|
1048
|
+
}
|
|
1049
|
+
} catch (e) {
|
|
1050
|
+
// Use default
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const html = `<!DOCTYPE html>
|
|
1054
|
+
<html>
|
|
1055
|
+
<head>
|
|
1056
|
+
<meta charset="UTF-8">
|
|
1057
|
+
<title>${filename}</title>
|
|
1058
|
+
<style>
|
|
1059
|
+
body, html {
|
|
1060
|
+
margin: 0;
|
|
1061
|
+
padding: 0;
|
|
1062
|
+
width: 100%;
|
|
1063
|
+
height: 100%;
|
|
1064
|
+
overflow: hidden;
|
|
1065
|
+
}
|
|
1066
|
+
</style>
|
|
1067
|
+
</head>
|
|
1068
|
+
<body>
|
|
1069
|
+
<div id="pw-pdf-viewer-container"></div>
|
|
1070
|
+
<script>
|
|
1071
|
+
// Wait for init script to load PdfJsViewer class, then render PDF directly
|
|
1072
|
+
(function checkAndRender() {
|
|
1073
|
+
// Check if PdfJsViewer class is available (from init script)
|
|
1074
|
+
if (typeof PdfJsViewer !== 'undefined') {
|
|
1075
|
+
console.log('[PW-PDF] [BLOB] Init script loaded, rendering PDF...');
|
|
1076
|
+
|
|
1077
|
+
const container = document.getElementById('pw-pdf-viewer-container');
|
|
1078
|
+
container.setAttribute('data-pw-pdf-viewer', 'true');
|
|
1079
|
+
container.style.cssText = 'position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #525252;';
|
|
1080
|
+
|
|
1081
|
+
// Create PDF.js viewer instance and render
|
|
1082
|
+
const viewer = new PdfJsViewer(container);
|
|
1083
|
+
viewer.renderPdf({
|
|
1084
|
+
pdfDataUrl: '${dataUrl}',
|
|
1085
|
+
filename: '${filename}',
|
|
1086
|
+
onReady: () => {
|
|
1087
|
+
console.log('[PW-PDF] [BLOB] ✅ PDF rendered successfully');
|
|
1088
|
+
},
|
|
1089
|
+
onError: (error) => {
|
|
1090
|
+
console.error('[PW-PDF] [BLOB] ❌ Failed to render PDF:', error);
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
} else {
|
|
1094
|
+
// PdfJsViewer not loaded yet, wait and retry
|
|
1095
|
+
setTimeout(checkAndRender, 50);
|
|
1096
|
+
}
|
|
1097
|
+
})();
|
|
1098
|
+
</script>
|
|
1099
|
+
</body>
|
|
1100
|
+
</html>`;
|
|
1101
|
+
|
|
1102
|
+
await route.fulfill({
|
|
1103
|
+
status: 200,
|
|
1104
|
+
headers: { 'Content-Type': 'text/html' },
|
|
1105
|
+
body: html
|
|
1106
|
+
});
|
|
1107
|
+
} catch (error) {
|
|
1108
|
+
debug('[SmartPlaywright] Blob URL interception failed:', error.message);
|
|
1109
|
+
await route.continue();
|
|
1110
|
+
}
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Other PDF resource types, continue normally
|
|
1116
|
+
await route.continue();
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
// 3. Set up window.open() interception on the parent page
|
|
1120
|
+
await this._page.evaluate(() => {
|
|
1121
|
+
if (window.__pw_setupPdfPopupInterception) {
|
|
1122
|
+
window.__pw_setupPdfPopupInterception();
|
|
1123
|
+
}
|
|
1124
|
+
}).catch((err) => {
|
|
1125
|
+
// Ignore errors if page is navigating - init script will handle it
|
|
1126
|
+
debug('[SmartPlaywright] window.open() interception setup skipped (page navigating):', err.message);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
// 4. Handle future popups (for debugging only)
|
|
1130
|
+
// Note: Context-level routing and init scripts automatically apply to popups
|
|
1131
|
+
this._page.on('popup', async () => {
|
|
1132
|
+
debug('[SmartPlaywright] Popup detected - context-level routing will handle PDF interception');
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// Mark context as having PDF injection set up (prevents duplicate setup)
|
|
1136
|
+
context.__pw_pdfInjectionSetup = true;
|
|
1137
|
+
|
|
1138
|
+
debug('[SmartPlaywright] ✅ PDF viewer injection setup complete');
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
debug('[SmartPlaywright] ❌ PDF setup error:', error);
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
771
1145
|
pushLocator(locator) {
|
|
772
1146
|
if (this.locators == undefined ) {
|
|
773
1147
|
this.locators = [];
|
|
@@ -853,6 +1227,11 @@ class SkyrampPlaywrightPage {
|
|
|
853
1227
|
}
|
|
854
1228
|
|
|
855
1229
|
async goto(url, options) {
|
|
1230
|
+
// Ensure PDF setup is complete before navigating
|
|
1231
|
+
if (this._pdfSetupPromise) {
|
|
1232
|
+
await this._pdfSetupPromise;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
856
1235
|
const transformedUrl = transformUrlForDocker(url);
|
|
857
1236
|
const result = await this._page.goto(transformedUrl, options);
|
|
858
1237
|
const content = await this._page.content();
|
|
@@ -863,6 +1242,21 @@ class SkyrampPlaywrightPage {
|
|
|
863
1242
|
} else {
|
|
864
1243
|
debug(`javascript not detected when visiting ${this._page.url()}`);
|
|
865
1244
|
}
|
|
1245
|
+
|
|
1246
|
+
// If navigated directly to a PDF URL, trigger PDF viewer injection
|
|
1247
|
+
if (url.toLowerCase().includes('.pdf')) {
|
|
1248
|
+
try {
|
|
1249
|
+
await this._page.evaluate(async () => {
|
|
1250
|
+
if (window.__pw_detectAndReplacePdfs) {
|
|
1251
|
+
await window.__pw_detectAndReplacePdfs();
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
debug('[SmartPlaywright] PDF viewer injected for direct navigation');
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
debug('[SmartPlaywright] PDF injection skipped:', error.message);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
866
1260
|
return result;
|
|
867
1261
|
}
|
|
868
1262
|
|
|
@@ -947,6 +1341,28 @@ class SkyrampPageAssertions {
|
|
|
947
1341
|
return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions, options);
|
|
948
1342
|
}
|
|
949
1343
|
|
|
1344
|
+
// Clip coordinates are document-relative (viewport coords + scrollX/Y at record time).
|
|
1345
|
+
// Scroll to bring the clip into view at a fixed margin below the viewport top, then
|
|
1346
|
+
// convert to viewport-relative coords for page.screenshot.
|
|
1347
|
+
let adjustedOptions = options;
|
|
1348
|
+
if (options && options.clip && typeof this._actualObject.evaluate === 'function') {
|
|
1349
|
+
const clip = options.clip;
|
|
1350
|
+
// TOP_MARGIN keeps the clip below any fixed headers (e.g. PDF toolbar = 56px).
|
|
1351
|
+
const TOP_MARGIN = 100;
|
|
1352
|
+
const scrolled = await this._actualObject.evaluate(({ y, margin }) => {
|
|
1353
|
+
window.scrollTo(0, Math.max(0, y - margin));
|
|
1354
|
+
return { x: window.scrollX || window.pageXOffset || 0, y: window.scrollY || window.pageYOffset || 0 };
|
|
1355
|
+
}, { y: clip.y, margin: TOP_MARGIN });
|
|
1356
|
+
adjustedOptions = {
|
|
1357
|
+
...options,
|
|
1358
|
+
clip: {
|
|
1359
|
+
...clip,
|
|
1360
|
+
x: clip.x - scrolled.x,
|
|
1361
|
+
y: clip.y - scrolled.y,
|
|
1362
|
+
},
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
|
|
950
1366
|
// Use Playwright's official API to get snapshot path
|
|
951
1367
|
// playwright.config.js
|
|
952
1368
|
//export default {
|
|
@@ -970,14 +1386,14 @@ class SkyrampPageAssertions {
|
|
|
970
1386
|
animations: 'disabled',
|
|
971
1387
|
caret: 'hide',
|
|
972
1388
|
scale: 'css',
|
|
973
|
-
...
|
|
1389
|
+
...adjustedOptions,
|
|
974
1390
|
path: snapshotPath // Always use our computed path
|
|
975
1391
|
});
|
|
976
1392
|
debug(`Generated baseline: ${snapshotPath}`);
|
|
977
1393
|
}
|
|
978
1394
|
|
|
979
1395
|
// Baseline exists (or just created): assert normally
|
|
980
|
-
return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions,
|
|
1396
|
+
return await this._playwrightExpectation.toHaveScreenshot(nameOrOptions, adjustedOptions);
|
|
981
1397
|
}
|
|
982
1398
|
}
|
|
983
1399
|
|
package/src/function.d.ts
CHANGED
|
@@ -18,3 +18,17 @@ export function checkStatusCode(
|
|
|
18
18
|
response: ResponseV2,
|
|
19
19
|
expectedStatus: string
|
|
20
20
|
): boolean;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validates that a request payload matches the expected reference payload.
|
|
24
|
+
* The reference is a stripped version of the recorded payload (volatile/falsy
|
|
25
|
+
* values removed). Throws an Error with details if validation fails.
|
|
26
|
+
*
|
|
27
|
+
* @param payload - The actual request payload (e.g. from response.request().postDataJSON()).
|
|
28
|
+
* @param reference - The expected (stripped) reference payload.
|
|
29
|
+
* @throws {Error} If the payload does not match the reference.
|
|
30
|
+
*/
|
|
31
|
+
export function checkRequestPayload(
|
|
32
|
+
payload: object,
|
|
33
|
+
reference: object
|
|
34
|
+
): void;
|
package/src/function.js
CHANGED
|
@@ -41,6 +41,151 @@ const checkStatusCodeWrapper = lib.func('checkStatusCodeWrapper', 'int', ['int',
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Returns true if a value is "truthy" for payload comparison purposes.
|
|
46
|
+
* Truthy: true, non-empty string, non-zero number, non-empty object/array.
|
|
47
|
+
* Falsy: false, null, undefined, 0, "", empty object, empty array.
|
|
48
|
+
* @param {*} value
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
function _isTruthy(value) {
|
|
52
|
+
if (value === null || value === undefined || value === false || value === 0 || value === '') {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.length > 0 && value.some(elem => _isTruthy(elem));
|
|
57
|
+
}
|
|
58
|
+
if (typeof value === 'object') {
|
|
59
|
+
return Object.keys(value).length > 0;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Returns true if a field key indicates a server-generated ID.
|
|
66
|
+
* @param {string} key
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function _isVolatileKey(key) {
|
|
70
|
+
return key === 'id' || key.endsWith('_id');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Returns true if a string value matches a known volatile pattern.
|
|
75
|
+
* @param {string} s
|
|
76
|
+
* @returns {boolean}
|
|
77
|
+
*/
|
|
78
|
+
function _isVolatileValue(value) {
|
|
79
|
+
if (typeof value === 'string') {
|
|
80
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) ||
|
|
81
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/.test(value) ||
|
|
82
|
+
/^eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value) ||
|
|
83
|
+
/^[0-9a-f]{24}$/i.test(value) ||
|
|
84
|
+
/^[0-9a-f]{32,}$/i.test(value) ||
|
|
85
|
+
/^https?:\/\/\S+\/[0-9a-f]{24,}/i.test(value);
|
|
86
|
+
}
|
|
87
|
+
if (typeof value === 'number') {
|
|
88
|
+
return value >= 1_000_000_000;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Recursively compares an actual request payload against a stripped reference
|
|
95
|
+
* payload. Returns an array of path-annotated error strings.
|
|
96
|
+
*
|
|
97
|
+
* Rules:
|
|
98
|
+
* - Every field in reference must exist in actual with the same value.
|
|
99
|
+
* - Every truthy field in actual that is NOT in reference is an error,
|
|
100
|
+
* unless the key or value matches a known volatile pattern.
|
|
101
|
+
* - Arrays must have the same length; null reference elements are skipped (volatile).
|
|
102
|
+
*
|
|
103
|
+
* @param {*} actual
|
|
104
|
+
* @param {*} reference
|
|
105
|
+
* @param {string} [path]
|
|
106
|
+
* @returns {string[]}
|
|
107
|
+
*/
|
|
108
|
+
function _comparePayload(actual, reference, path) {
|
|
109
|
+
const loc = path || 'root';
|
|
110
|
+
const errors = [];
|
|
111
|
+
|
|
112
|
+
// Null reference element means volatile/falsy — skip
|
|
113
|
+
if (reference === null || reference === undefined) {
|
|
114
|
+
return errors;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (actual === null || actual === undefined) {
|
|
118
|
+
errors.push(`${loc}: expected ${JSON.stringify(reference)}, got ${actual}`);
|
|
119
|
+
return errors;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Array comparison
|
|
123
|
+
if (Array.isArray(reference)) {
|
|
124
|
+
if (!Array.isArray(actual)) {
|
|
125
|
+
errors.push(`${loc}: expected array, got ${typeof actual}`);
|
|
126
|
+
return errors;
|
|
127
|
+
}
|
|
128
|
+
if (actual.length !== reference.length) {
|
|
129
|
+
errors.push(`${loc}: expected array length ${reference.length}, got ${actual.length}`);
|
|
130
|
+
return errors;
|
|
131
|
+
}
|
|
132
|
+
for (let i = 0; i < reference.length; i++) {
|
|
133
|
+
if (reference[i] === null) continue; // volatile element — skip
|
|
134
|
+
errors.push(..._comparePayload(actual[i], reference[i], `${loc}[${i}]`));
|
|
135
|
+
}
|
|
136
|
+
return errors;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Object comparison
|
|
140
|
+
if (typeof reference === 'object') {
|
|
141
|
+
if (typeof actual !== 'object' || Array.isArray(actual)) {
|
|
142
|
+
errors.push(`${loc}: expected object, got ${Array.isArray(actual) ? 'array' : typeof actual}`);
|
|
143
|
+
return errors;
|
|
144
|
+
}
|
|
145
|
+
// Check all reference fields exist and match
|
|
146
|
+
for (const key of Object.keys(reference)) {
|
|
147
|
+
if (!(key in actual)) {
|
|
148
|
+
errors.push(`${loc}.${key}: missing (expected ${JSON.stringify(reference[key])})`);
|
|
149
|
+
} else {
|
|
150
|
+
errors.push(..._comparePayload(actual[key], reference[key], `${loc}.${key}`));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Check for unexpected truthy fields in actual (skip volatile keys/values)
|
|
154
|
+
for (const key of Object.keys(actual)) {
|
|
155
|
+
if (!(key in reference) && _isTruthy(actual[key]) && !_isVolatileKey(key) && !_isVolatileValue(actual[key])) {
|
|
156
|
+
errors.push(`${loc}.${key}: unexpected truthy field (value: ${JSON.stringify(actual[key])})`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return errors;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Primitive comparison
|
|
163
|
+
if (actual !== reference) {
|
|
164
|
+
errors.push(`${loc}: expected ${JSON.stringify(reference)}, got ${JSON.stringify(actual)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return errors;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Validates that a request payload matches the expected reference payload.
|
|
172
|
+
* The reference is a stripped version of the recorded payload (volatile/falsy
|
|
173
|
+
* values removed). Throws an Error with details if validation fails.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} payload - The actual request payload (e.g. from response.request().postDataJSON()).
|
|
176
|
+
* @param {object} reference - The expected (stripped) reference payload.
|
|
177
|
+
* @throws {Error} If the payload does not match the reference.
|
|
178
|
+
*/
|
|
179
|
+
function checkRequestPayload(payload, reference) {
|
|
180
|
+
const errors = _comparePayload(payload, reference, '');
|
|
181
|
+
if (errors.length > 0) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`Request payload validation failed:\n${errors.join('\n')}`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
44
188
|
module.exports = {
|
|
45
|
-
checkStatusCode
|
|
189
|
+
checkStatusCode,
|
|
190
|
+
checkRequestPayload,
|
|
46
191
|
};
|
package/src/index.js
CHANGED
|
@@ -17,8 +17,8 @@ const { AsyncScenario, AsyncRequest } = require('./classes/AsyncScenario');
|
|
|
17
17
|
const { LoadTestConfig } = require('./classes/LoadTestConfig');
|
|
18
18
|
const AsyncTestStatus = require('./classes/AsyncTestStatus');
|
|
19
19
|
const MockV2 = require('./classes/MockV2');
|
|
20
|
-
const { getValue, getResponseValue, checkSchema, iterate, pushToolEvent } = require('./utils');
|
|
21
|
-
const { checkStatusCode } = require('./function');
|
|
20
|
+
const { getValue, getResponseValue, checkSchema, iterate, pushToolEvent, getBaseUrl } = require('./utils');
|
|
21
|
+
const { checkStatusCode, checkRequestPayload } = require('./function');
|
|
22
22
|
const { newSkyrampPlaywrightPage, expect } = require('./classes/SmartPlaywright');
|
|
23
23
|
const {
|
|
24
24
|
workspaceConfigSchema,
|
|
@@ -54,9 +54,11 @@ module.exports = {
|
|
|
54
54
|
getValue,
|
|
55
55
|
getResponseValue,
|
|
56
56
|
checkStatusCode,
|
|
57
|
+
checkRequestPayload,
|
|
57
58
|
checkSchema,
|
|
58
59
|
iterate,
|
|
59
60
|
pushToolEvent,
|
|
61
|
+
getBaseUrl,
|
|
60
62
|
newSkyrampPlaywrightPage,
|
|
61
63
|
expect,
|
|
62
64
|
workspaceConfigSchema,
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) Skyramp Corporation.
|
|
3
|
+
*
|
|
4
|
+
* Bundled PDF.js viewer for execution-time injection
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Self-contained PDF viewer injection script
|
|
9
|
+
* This script will be injected into pages during test execution to replace Chrome's PDF plugin with PDF.js
|
|
10
|
+
*/
|
|
11
|
+
export declare const PDF_VIEWER_INJECTION_SCRIPT: string;
|