UNPKG

11 kBJavaScriptView Raw
1"use strict";
2
3const querystring = require("querystring");
4const getHeaders = require("./lib/getHeaders");
5const makeAbsolute = require("./lib/makeAbsolute");
6const Request = require("request");
7const supertest = require("supertest");
8const url = require("url");
9const vm = require("vm");
10const {Document, Fetch, Window} = require("./lib");
11const assert = require("assert");
12
13module.exports = Tallahassee;
14
15function Tallahassee(app, options = {}) {
16 const agent = supertest.agent(app);
17 return {
18 navigateTo,
19 load,
20 };
21
22 function navigateTo(linkUrl, headers = {}, statusCode = 200) {
23 if (options.headers) {
24 headers = {
25 ...options.headers,
26 ...headers,
27 };
28 }
29
30 let numRedirects = 0;
31 for (const key in headers) {
32 if (key.toLowerCase() === "cookie") {
33 agent.jar.setCookies(headers[key].split(";").map((c) => c.trim()).filter(Boolean));
34 }
35 }
36
37 return makeRequest(linkUrl)
38 .then((resp) => {
39 assert.equal(resp.statusCode, statusCode, `Unexepected status code. Expected: ${statusCode}. Actual: ${resp.statusCode}`);
40 assert(resp.headers["content-type"].match(/text\/html/i), `Unexepected content type. Expected: text/html. Actual: ${resp.headers["content-type"]}`);
41 return resp;
42 })
43 .then(load);
44
45 function makeRequest(reqUrl) {
46 let request;
47 const parsedUrl = url.parse(reqUrl);
48
49 if (parsedUrl.host && parsedUrl.host !== headers.host) {
50 request = new Promise((resolve, reject) => {
51 Request.get(reqUrl, { followRedirect: false }, (externalReqErr, externalReqRes) => {
52 if (externalReqErr) {
53 return reject(externalReqErr);
54 }
55 return resolve(externalReqRes);
56 });
57 });
58 } else {
59 if (parsedUrl.host) {
60 reqUrl = reqUrl.replace(`${parsedUrl.protocol}//${parsedUrl.host}`, "");
61 }
62 request = agent.get(reqUrl).redirects(0);
63 for (const key in headers) {
64 if (key.toLowerCase() !== "cookie") {
65 request.set(key, headers[key]);
66 }
67 }
68 }
69
70 return request.then((res) => {
71 if (res.statusCode > 300 && res.statusCode < 308) {
72 numRedirects++;
73 if (numRedirects > 20) {
74 throw new Error("Too many redirects");
75 }
76 return makeRequest(res.headers.location);
77 }
78 return res;
79 });
80 }
81 }
82
83 function load(resp) {
84 let pending, currentPageXOffset, currentPageYOffset;
85 let elementsToScroll = () => {};
86 const stickedElements = [];
87
88 const headers = getHeaders(resp.request);
89 const document = Document(resp, agent.jar);
90 const window = Window(resp, {
91 fetch: Fetch(agent, resp),
92 get document() {
93 return document;
94 },
95 });
96
97 Object.defineProperty(document, "window", {
98 get() {
99 return window;
100 }
101 });
102
103 const browserContext = {
104 $: document.$,
105 document,
106 focus,
107 focusIframe,
108 navigateTo,
109 runScripts,
110 setElementsToScroll,
111 scrollToBottomOfElement,
112 scrollToTopOfElement,
113 stickElementToTop,
114 unstickElementFromTop,
115 window,
116 response: resp
117 };
118
119 Object.defineProperty(browserContext, "_pending", {
120 get: () => pending
121 });
122
123 currentPageXOffset = window.pageXOffset;
124 currentPageYOffset = window.pageYOffset;
125
126 document.addEventListener("submit", onDocumentSubmit);
127 window.addEventListener("scroll", onWindowScroll);
128
129 focus();
130
131 return browserContext;
132
133 function focus() {
134 }
135
136 function onDocumentSubmit(event) {
137 if (event.target.tagName === "FORM") {
138 pending = new Promise((resolve) => {
139 process.nextTick(navigate, resolve);
140 });
141 }
142
143 function navigate(resolve) {
144 if (event.defaultPrevented) return resolve();
145
146 const form = event.target;
147 const method = form.getAttribute("method") || "GET";
148 const formaction = (event._submitElement && event._submitElement.getAttribute("formaction")) || form.getAttribute("action");
149 const action = formaction || window.location.pathname + (window.location.search ? window.location.search : "");
150 const submitHeaders = {...options.headers, ...headers, cookie: agent.jar.getCookies({path: action}).toValueString()};
151
152 const formData = getFormData(form, event._submitElement);
153
154 if (method.toUpperCase() === "GET") {
155 const p = url.parse(action, true);
156 Object.assign(p.query, formData);
157
158 const navigation = navigateTo(url.format(p), submitHeaders);
159 resolve(navigation);
160 } else if (method.toUpperCase() === "POST") {
161 if (action.startsWith("/") || url.parse(action).host === submitHeaders.host) {
162 agent.post(url.parse(action).path)
163 .set(submitHeaders)
164 .set("Content-Type", "application/x-www-form-urlencoded")
165 .send(querystring.stringify(formData))
166 .then((postResp) => {
167 if ([301, 302].includes(postResp.statusCode)) return navigateTo(postResp.headers.location);
168 return load(postResp);
169 })
170 .then(resolve);
171 } else {
172 Request.post(action, {
173 headers: {
174 "Content-Type": "application/x-www-form-urlencoded"
175 },
176 body: querystring.stringify(formData)
177 }, (err, res) => {
178 if (err) {
179 throw err;
180 }
181 resolve(load(res));
182 });
183 }
184 }
185 }
186 }
187
188 function runScripts(context) {
189 context = context || document.documentElement;
190 context.$elm.find("script").each((idx, elm) => {
191 const $script = document.$(elm);
192 const scriptType = $script.attr("type");
193 if (scriptType && !/javascript/i.test(scriptType)) return;
194
195 const scriptBody = $script.html();
196 if (scriptBody) vm.runInNewContext(scriptBody, window);
197 });
198 }
199
200 function setElementsToScroll(elmsToScrollFn) {
201 elementsToScroll = elmsToScrollFn;
202 }
203
204 function onWindowScroll() {
205 if (!elementsToScroll) return;
206 const elms = elementsToScroll(document);
207 if (!elms || !elms.length) return;
208
209 const {pageXOffset, pageYOffset} = window;
210 const deltaX = currentPageXOffset - pageXOffset;
211 const deltaY = currentPageYOffset - pageYOffset;
212
213 elms.slice().forEach((elm) => {
214 if (isElementSticky(elm)) return;
215
216 const {left, right, top, bottom} = elm.getBoundingClientRect();
217 elm._setBoundingClientRect({
218 left: (left || 0) + deltaX,
219 right: (right || 0) + deltaX,
220 top: (top || 0) + deltaY,
221 bottom: (bottom || 0) + deltaY,
222 });
223 });
224
225 currentPageXOffset = pageXOffset;
226 currentPageYOffset = pageYOffset;
227 }
228
229 function scrollToTopOfElement(element, offset = 0) {
230 if (isElementSticky(element)) throw new Error("Cannot scroll to sticky element");
231
232 const {top} = element.getBoundingClientRect();
233
234 const pageYOffset = window.pageYOffset;
235 let newYOffset = pageYOffset + top - offset;
236 if (newYOffset < 0) newYOffset = 0;
237
238 window.scroll(window.pageXOffset, newYOffset);
239 }
240
241 function scrollToBottomOfElement(element, offset = 0) {
242 if (isElementSticky(element)) throw new Error("Cannot scroll to sticky element");
243 const {height} = element.getBoundingClientRect();
244 const offsetFromBottom = window.innerHeight - height;
245 return scrollToTopOfElement(element, offsetFromBottom + offset);
246 }
247
248 function stickElementToTop(element) {
249 if (isElementSticky(element)) return;
250
251 const {top, height} = element.getBoundingClientRect();
252 element._tallahasseePositionBeforeSticky = window.pageYOffset + top;
253 element._setBoundingClientRect({
254 top: 0,
255 bottom: (height || 0)
256 });
257 stickedElements.push(element);
258 }
259
260 function unstickElementFromTop(element) {
261 const idx = stickedElements.indexOf(element);
262 if (idx < 0) return;
263 stickedElements.splice(idx, 1);
264 const top = element._tallahasseePositionBeforeSticky - window.pageYOffset;
265 const {height} = element.getBoundingClientRect();
266 element._setBoundingClientRect({
267 top: top,
268 bottom: height ? top + height : top
269 });
270 element._tallahasseePositionBeforeSticky = undefined;
271 }
272
273 function isElementSticky(element) {
274 return stickedElements.indexOf(element) > -1;
275 }
276
277 async function focusIframe(element, src) {
278 if (!element) return;
279 if (!element.tagName === "IFRAME") return;
280
281 src = src || element.src;
282
283 const srcUrl = makeAbsolute(browserContext.window.location, src);
284 const parsedUrl = url.parse(srcUrl);
285
286 if (parsedUrl.host !== browserContext.window.location.host) return requestExternalContent(srcUrl);
287
288 const iframeScope = await navigateTo(parsedUrl.path, headers);
289 iframeScope.window.frameElement = element;
290 iframeScope.window.top = browserContext.window;
291
292 return iframeScope;
293
294 function requestExternalContent(externalUrl) {
295 const prom = new Promise((resolve, reject) => {
296 Request.get(externalUrl, (err, getResp) => {
297 if (err) return reject(err);
298
299 const {request, body: text} = getResp;
300 const {href} = request;
301 request.url = href;
302
303 resolve({request, text});
304 });
305 });
306
307 return prom.then((scopeResp) => {
308 return Tallahassee(app).load(scopeResp);
309 }).then((scopedBrowser) => {
310 scopedBrowser.window.top = getLockedWindow(scopedBrowser.window.location.href);
311 return scopedBrowser;
312 });
313 }
314 }
315
316 function getLockedWindow(frameSrc) {
317 const lockedWindow = {};
318 const location = {};
319
320 const origin = url.parse(frameSrc);
321
322 lockedWindow.location = new Proxy(location, {
323 set: unauth,
324 get: unauth,
325 deleteProperty: unauth
326 });
327
328 return lockedWindow;
329
330 function unauth() {
331 throw new Error(`Blocked a frame with origin "${origin.protocol}//${origin.host}" from accessing a cross-origin frame.`);
332 }
333 }
334 }
335}
336
337function getFormData(form, submitElement) {
338 const inputs = form.getElementsByTagName("input");
339
340 const payload = inputs.reduce((acc, input) => {
341 if (input.disabled) return acc;
342
343 if (input.name && input.value) {
344 if (input.type === "radio" || input.type === "checkbox") {
345 if (input.checked) {
346 acc[input.name] = acc[input.name] || [];
347 acc[input.name].push(input.value);
348 }
349 } else {
350 acc[input.name] = input.value;
351 }
352 }
353 return acc;
354 }, {});
355
356 if (submitElement && submitElement.name) {
357 payload[submitElement.name] = submitElement.value;
358 }
359
360 return payload;
361}