UNPKG

10.7 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 action = form.getAttribute("action") || window.location.pathname + (window.location.search ? window.location.search : "");
149 const submitHeaders = {...options.headers, ...headers, cookie: agent.jar.getCookies({path: action}).toValueString()};
150
151 const formData = getFormData(form);
152
153 if (method.toUpperCase() === "GET") {
154 const p = url.parse(action, true);
155 Object.assign(p.query, formData);
156
157 const navigation = navigateTo(url.format(p), submitHeaders);
158 resolve(navigation);
159 } else if (method.toUpperCase() === "POST") {
160 if (action.startsWith("/") || url.parse(action).host === submitHeaders.host) {
161 agent.post(url.parse(action).path)
162 .set(submitHeaders)
163 .set("Content-Type", "application/x-www-form-urlencoded")
164 .send(querystring.stringify(formData))
165 .then((postResp) => {
166 if ([301, 302].includes(postResp.statusCode)) return navigateTo(postResp.headers.location);
167 return load(postResp);
168 })
169 .then(resolve);
170 } else {
171 Request.post(action, {
172 headers: {
173 "Content-Type": "application/x-www-form-urlencoded"
174 },
175 body: querystring.stringify(formData)
176 }, (err, res) => {
177 if (err) {
178 throw err;
179 }
180 resolve(load(res));
181 });
182 }
183 }
184 }
185 }
186
187 function runScripts(context) {
188 context = context || document.documentElement;
189 context.$elm.find("script").each((idx, elm) => {
190 const $script = document.$(elm);
191 const scriptType = $script.attr("type");
192 if (scriptType && !/javascript/i.test(scriptType)) return;
193
194 const scriptBody = $script.html();
195 if (scriptBody) vm.runInNewContext(scriptBody, window);
196 });
197 }
198
199 function setElementsToScroll(elmsToScrollFn) {
200 elementsToScroll = elmsToScrollFn;
201 }
202
203 function onWindowScroll() {
204 if (!elementsToScroll) return;
205 const elms = elementsToScroll(document);
206 if (!elms || !elms.length) return;
207
208 const {pageXOffset, pageYOffset} = window;
209 const deltaX = currentPageXOffset - pageXOffset;
210 const deltaY = currentPageYOffset - pageYOffset;
211
212 elms.slice().forEach((elm) => {
213 if (isElementSticky(elm)) return;
214
215 const {left, right, top, bottom} = elm.getBoundingClientRect();
216 elm._setBoundingClientRect({
217 left: (left || 0) + deltaX,
218 right: (right || 0) + deltaX,
219 top: (top || 0) + deltaY,
220 bottom: (bottom || 0) + deltaY,
221 });
222 });
223
224 currentPageXOffset = pageXOffset;
225 currentPageYOffset = pageYOffset;
226 }
227
228 function scrollToTopOfElement(element, offset = 0) {
229 if (isElementSticky(element)) throw new Error("Cannot scroll to sticky element");
230
231 const {top} = element.getBoundingClientRect();
232
233 const pageYOffset = window.pageYOffset;
234 let newYOffset = pageYOffset + top - offset;
235 if (newYOffset < 0) newYOffset = 0;
236
237 window.scroll(window.pageXOffset, newYOffset);
238 }
239
240 function scrollToBottomOfElement(element, offset = 0) {
241 if (isElementSticky(element)) throw new Error("Cannot scroll to sticky element");
242 const {height} = element.getBoundingClientRect();
243 const offsetFromBottom = window.innerHeight - height;
244 return scrollToTopOfElement(element, offsetFromBottom + offset);
245 }
246
247 function stickElementToTop(element) {
248 if (isElementSticky(element)) return;
249
250 const {top, height} = element.getBoundingClientRect();
251 element._tallahasseePositionBeforeSticky = window.pageYOffset + top;
252 element._setBoundingClientRect({
253 top: 0,
254 bottom: (height || 0)
255 });
256 stickedElements.push(element);
257 }
258
259 function unstickElementFromTop(element) {
260 const idx = stickedElements.indexOf(element);
261 if (idx < 0) return;
262 stickedElements.splice(idx, 1);
263 const top = element._tallahasseePositionBeforeSticky - window.pageYOffset;
264 const {height} = element.getBoundingClientRect();
265 element._setBoundingClientRect({
266 top: top,
267 bottom: height ? top + height : top
268 });
269 element._tallahasseePositionBeforeSticky = undefined;
270 }
271
272 function isElementSticky(element) {
273 return stickedElements.indexOf(element) > -1;
274 }
275
276 async function focusIframe(element, src) {
277 if (!element) return;
278 if (!element.tagName === "IFRAME") return;
279
280 src = src || element.src;
281
282 const srcUrl = makeAbsolute(browserContext.window.location, src);
283 const parsedUrl = url.parse(srcUrl);
284
285 if (parsedUrl.host !== browserContext.window.location.host) return requestExternalContent(srcUrl);
286
287 const iframeScope = await navigateTo(parsedUrl.path, headers);
288 iframeScope.window.frameElement = element;
289 iframeScope.window.top = browserContext.window;
290
291 return iframeScope;
292
293 function requestExternalContent(externalUrl) {
294 const prom = new Promise((resolve, reject) => {
295 Request.get(externalUrl, (err, getResp) => {
296 if (err) return reject(err);
297
298 const {request, body: text} = getResp;
299 const {href} = request;
300 request.url = href;
301
302 resolve({request, text});
303 });
304 });
305
306 return prom.then((scopeResp) => {
307 return Tallahassee(app).load(scopeResp);
308 }).then((scopedBrowser) => {
309 scopedBrowser.window.top = getLockedWindow(scopedBrowser.window.location.href);
310 return scopedBrowser;
311 });
312 }
313 }
314
315 function getLockedWindow(frameSrc) {
316 const lockedWindow = {};
317 const location = {};
318
319 const origin = url.parse(frameSrc);
320
321 lockedWindow.location = new Proxy(location, {
322 set: unauth,
323 get: unauth,
324 deleteProperty: unauth
325 });
326
327 return lockedWindow;
328
329 function unauth() {
330 throw new Error(`Blocked a frame with origin "${origin.protocol}//${origin.host}" from accessing a cross-origin frame.`);
331 }
332 }
333 }
334}
335
336function getFormData(form) {
337 const inputs = form.getElementsByTagName("input");
338
339 return inputs.reduce((acc, input) => {
340 if (input.disabled) return acc;
341
342 if (input.name && input.value) {
343 if (input.type === "radio" || input.type === "checkbox") {
344 if (input.checked) {
345 acc[input.name] = acc[input.name] || [];
346 acc[input.name].push(input.value);
347 }
348 } else {
349 acc[input.name] = input.value;
350 }
351 }
352 return acc;
353 }, {});
354}