UNPKG

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