Bug 1754407 - [devtools] Cover fetching source text content with a mochitest. r=bomsy

This replaces a jest test with some real usecases.

Differential Revision: https://phabricator.services.mozilla.com/D140067
This commit is contained in:
Alexandre Poirot 2022-03-11 10:23:32 +00:00
Родитель 55a47e51aa
Коммит 3976d30b56
3 изменённых файлов: 287 добавлений и 316 удалений

Просмотреть файл

@ -1,316 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
import React from "react";
import { shallow } from "enzyme";
import Editor from "../index";
import { getDocument } from "../../../utils/editor/source-documents";
import * as asyncValue from "../../../utils/async-value";
function generateDefaults(overrides) {
return {
toggleBreakpoint: jest.fn(),
updateViewport: jest.fn(),
toggleDisabledBreakpoint: jest.fn(),
...overrides,
};
}
function createMockEditor() {
return {
codeMirror: {
doc: {},
getOption: jest.fn(),
setOption: jest.fn(),
scrollTo: jest.fn(),
charCoords: ({ line, ch }) => ({ top: line, left: ch }),
getScrollerElement: () => ({ offsetWidth: 0, offsetHeight: 0 }),
getScrollInfo: () => ({
top: 0,
left: 0,
clientWidth: 0,
clientHeight: 0,
}),
defaultCharWidth: () => 0,
defaultTextHeight: () => 0,
display: { gutters: { querySelector: jest.fn() } },
},
setText: jest.fn(),
on: jest.fn(),
off: jest.fn(),
createDocument: () => {
let val;
return {
getLine: line => "",
getValue: () => val,
setValue: newVal => (val = newVal),
};
},
replaceDocument: jest.fn(),
setMode: jest.fn(),
};
}
function createMockSourceWithContent(overrides) {
const {
loadedState = "loaded",
text = "the text",
contentType = undefined,
error = undefined,
...otherOverrides
} = overrides;
const source = {
id: "foo",
url: "foo",
...otherOverrides,
};
let content = null;
if (loadedState === "loaded") {
if (typeof text !== "string") {
throw new Error("Cannot create a non-text source");
}
content = error
? asyncValue.rejected(error)
: asyncValue.fulfilled({
type: "text",
value: text,
contentType: contentType || undefined,
});
}
return {
...source,
content,
};
}
function render(overrides = {}) {
const props = generateDefaults(overrides);
const mockEditor = createMockEditor();
const component = shallow(<Editor.WrappedComponent {...props} />, {
context: {
shortcuts: { on: jest.fn() },
},
disableLifecycleMethods: true,
});
return { component, props, mockEditor };
}
describe("Editor", () => {
describe("When empty", () => {
it("should render", async () => {
const { component } = render();
expect(component).toMatchSnapshot();
});
});
describe("When loading initial source", () => {
it("should show a loading message", async () => {
const { component, mockEditor } = render();
await component.setState({ editor: mockEditor });
component.setProps({
selectedSource: {
source: { loadedState: "loading" },
content: null,
},
});
expect(mockEditor.replaceDocument.mock.calls[0][0].getValue()).toBe(
"Loading…"
);
expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([]);
});
});
describe("When loaded", () => {
it("should show text", async () => {
const { component, mockEditor, props } = render({});
await component.setState({ editor: mockEditor });
await component.setProps({
...props,
selectedSource: createMockSourceWithContent({
loadedState: "loaded",
}),
selectedLocation: { sourceId: "foo", line: 3, column: 1 },
});
expect(mockEditor.setText.mock.calls).toEqual([["the text"]]);
expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([[1, 2]]);
});
});
describe("When error", () => {
it("should show error text", async () => {
const { component, mockEditor, props } = render({});
await component.setState({ editor: mockEditor });
await component.setProps({
...props,
selectedSource: createMockSourceWithContent({
loadedState: "loaded",
text: undefined,
error: "error text",
}),
selectedLocation: { sourceId: "bad-foo", line: 3, column: 1 },
});
expect(mockEditor.setText.mock.calls).toEqual([
["Error loading this URI: error text"],
]);
});
it("should show wasm error", async () => {
const { component, mockEditor, props } = render({});
await component.setState({ editor: mockEditor });
await component.setProps({
...props,
selectedSource: createMockSourceWithContent({
loadedState: "loaded",
isWasm: true,
text: undefined,
error: "blah WebAssembly binary source is not available blah",
}),
selectedLocation: { sourceId: "bad-foo", line: 3, column: 1 },
});
expect(mockEditor.setText.mock.calls).toEqual([
["Please refresh to debug this module"],
]);
});
});
describe("When navigating to a loading source", () => {
it("should show loading message and not scroll", async () => {
const { component, mockEditor, props } = render({});
await component.setState({ editor: mockEditor });
await component.setProps({
...props,
selectedSource: createMockSourceWithContent({
loadedState: "loaded",
}),
selectedLocation: { sourceId: "foo", line: 3, column: 1 },
});
// navigate to a new source that is still loading
await component.setProps({
...props,
selectedSource: createMockSourceWithContent({
id: "bar",
loadedState: "loading",
}),
selectedLocation: { sourceId: "bar", line: 1, column: 1 },
});
expect(mockEditor.replaceDocument.mock.calls[1][0].getValue()).toBe(
"Loading…"
);
expect(mockEditor.setText.mock.calls).toEqual([["the text"]]);
expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([[1, 2]]);
});
it("should set the mode when symbols load", async () => {
const { component, mockEditor, props } = render({});
await component.setState({ editor: mockEditor });
const selectedSource = createMockSourceWithContent({
loadedState: "loaded",
contentType: "javascript",
});
await component.setProps({ ...props, selectedSource });
const symbols = { hasJsx: true };
await component.setProps({
...props,
selectedSource,
symbols,
});
expect(mockEditor.setMode.mock.calls).toEqual([
[{ name: "javascript" }],
[{ name: "jsx" }],
]);
});
it("should not re-set the mode when the location changes", async () => {
const { component, mockEditor, props } = render({});
await component.setState({ editor: mockEditor });
const selectedSource = createMockSourceWithContent({
loadedState: "loaded",
contentType: "javascript",
});
await component.setProps({ ...props, selectedSource });
// symbols are parsed
const symbols = { hasJsx: true };
await component.setProps({
...props,
selectedSource,
symbols,
});
// selectedLocation changes e.g. pausing/stepping
mockEditor.codeMirror.doc = getDocument(selectedSource.id);
mockEditor.codeMirror.getOption = () => ({ name: "jsx" });
const selectedLocation = { sourceId: "foo", line: 4, column: 1 };
await component.setProps({
...props,
selectedSource,
symbols,
selectedLocation,
});
expect(mockEditor.setMode.mock.calls).toEqual([
[{ name: "javascript" }],
[{ name: "jsx" }],
]);
});
});
describe("When navigating to a loaded source", () => {
it("should show text and then scroll", async () => {
const { component, mockEditor, props } = render({});
await component.setState({ editor: mockEditor });
await component.setProps({
...props,
selectedSource: createMockSourceWithContent({
loadedState: "loading",
}),
selectedLocation: { sourceId: "foo", line: 1, column: 1 },
});
// navigate to a new source that is still loading
await component.setProps({
...props,
selectedSource: createMockSourceWithContent({
loadedState: "loaded",
}),
selectedLocation: { sourceId: "foo", line: 1, column: 1 },
});
expect(mockEditor.replaceDocument.mock.calls[0][0].getValue()).toBe(
"Loading…"
);
expect(mockEditor.setText.mock.calls).toEqual([["the text"]]);
expect(mockEditor.codeMirror.scrollTo.mock.calls).toEqual([[1, 0]]);
});
});
});

Просмотреть файл

@ -99,6 +99,7 @@ skip-if = debug # Window leaks: bug 1575332
[browser_dbg-expressions-thread.js]
skip-if = !fission # threads panel only shows remote frame when fission is enabled.
[browser_dbg-expressions-watch.js]
[browser_dbg-features-source-text-content.js]
[browser_dbg-fission-frame-breakpoint.js]
[browser_dbg-fission-frame-pause-exceptions.js]
[browser_dbg-fission-frame-sources.js]

Просмотреть файл

@ -0,0 +1,286 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */
// Test Source Text content fetching
"use strict";
const httpServer = createTestHTTPServer();
const BASE_URL = `http://localhost:${httpServer.identity.primaryPort}/`;
const loadCounts = {};
/**
* Simple tests, asserting that we correctly display source text content in CodeMirror
*/
const INDEX_PAGE_CONTENT = `<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="/normal-script.js"></script>
<script type="text/javascript" src="/slow-loading-script.js"></script>
<script type="text/javascript" src="/http-error-script.js"></script>
<script>
console.log("inline script");
</script>
</head>
</html>`;
httpServer.registerPathHandler("/index.html", (request, response) => {
loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
response.setStatusLine(request.httpVersion, 200, "OK");
response.write(INDEX_PAGE_CONTENT);
});
httpServer.registerPathHandler("/normal-script.js", (request, response) => {
loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
response.setHeader("Content-Type", "application/javascript");
response.write(`console.log("normal script")`);
});
httpServer.registerPathHandler(
"/slow-loading-script.js",
(request, response) => {
loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
response.processAsync();
// eslint-disable-next-line mozilla/no-arbitrary-setTimeout
setTimeout(function() {
response.setHeader("Content-Type", "application/javascript");
response.write(`console.log("slow loading script")`);
response.finish();
}, 1000);
}
);
httpServer.registerPathHandler("/http-error-script.js", (request, response) => {
loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
response.setStatusLine(request.httpVersion, 404, "Not found");
response.write(`console.log("http error")`);
});
add_task(async function testSourceTextContent() {
const tab = await addTab(BASE_URL + "index.html");
is(
loadCounts["/index.html"],
1,
"index.html is loaded once before opening devtools"
);
// For some reason external to the debugger, we issue two requests to scripts having http error codes.
// These two requests are done before opening the debugger.
is(
loadCounts["/http-error-script.js"],
2,
"We loaded http-error-script.js twice."
);
const toolbox = await openToolboxForTab(tab, "jsdebugger");
const dbg = createDebuggerContext(toolbox);
await waitForSources(
dbg,
"index.html",
"normal-script.js",
"slow-loading-script.js"
);
await selectSource(dbg, "normal-script.js");
is(getCM(dbg).getValue(), `console.log("normal script")`);
await selectSource(dbg, "slow-loading-script.js");
is(getCM(dbg).getValue(), `console.log("slow loading script")`);
await selectSource(dbg, "index.html");
is(getCM(dbg).getValue(), INDEX_PAGE_CONTENT);
ok(
!sourceExists(dbg, "http-error-script.js"),
"scripts with HTTP error code do not appear in the source list"
);
// The HTML page is fetched a second time because it was loaded before opening DevTools
// and Spidermonkey doesn't store HTML page in any cache
is(loadCounts["/index.html"], 2, "We loaded index.html twice");
is(
loadCounts["/normal-script.js"],
1,
"We loaded normal-script.js only once"
);
is(
loadCounts["/slow-loading-script.js"],
1,
"We loaded slow-loading-script.js only once"
);
is(
loadCounts["/http-error-script.js"],
2,
"We loaded http-error-script.js twice, only before the debugger is opened"
);
});
/**
* In this test, we force a GC before loading DevTools.
* So that Spidermonkey will no longer have access to the sources
* and another request should be issues to load the source text content.
*/
const GARBAGED_PAGE_CONTENT = `<!DOCTYPE html>
<html>
<head>
<script type="text/javascript" src="/garbaged-script.js"></script>
<script>
SpecialPowers.gc();
</script>
</head>
</html>`;
httpServer.registerPathHandler(
"/garbaged-collected.html",
(request, response) => {
loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
response.setStatusLine(request.httpVersion, 200, "OK");
response.write(GARBAGED_PAGE_CONTENT);
}
);
httpServer.registerPathHandler("/garbaged-script.js", (request, response) => {
loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
response.setHeader("Content-Type", "application/javascript");
response.write(`console.log("garbaged script ${loadCounts[request.path]}")`);
});
add_task(async function testGarbageCollectedSourceTextContent() {
const dbg = await initDebuggerWithAbsoluteURL(
BASE_URL + "garbaged-collected.html",
"garbaged-collected.html",
"garbaged-script.js"
);
await selectSource(dbg, "garbaged-script.js");
// XXX Bug 1758454 - Source content of GC-ed script can be wrong!
// Even if we have to issue a new HTTP request for this source,
// we should be using HTTP cache and retrieve the first served version which
// is the one that actually runs in the page!
// We should be displaying `console.log("garbaged script 1")`,
// but instead, a new HTTP request is dispatched and we get a new content.
is(getCM(dbg).getValue(), `console.log("garbaged script 2")`);
// The HTML page is fetched a second time because it was loaded before opening DevTools
// and Spidermonkey doesn't store HTML page in any cache
is(
loadCounts["/garbaged-collected.html"],
1,
"We loaded the html page once as we haven't tried to display it in the debugger"
);
is(
loadCounts["/garbaged-script.js"],
2,
"We loaded the garbaged script twice as we lost its content"
);
});
/**
* Test failures when trying to open the source text content.
*
* In this test we load an html page
* - with inline source (so that it shows up in the debugger)
* - it first loads fine so that it shows up
* - initDebuggerWithAbsoluteURL will first load the document before the debugger
* - so the debugger will have to fetch the html page content via a network request
* - the test page will return a 404 error code on the second load attempt
*/
let loadCount = 0;
httpServer.registerPathHandler(
"/200-then-404-page.html",
(request, response) => {
loadCount++;
if (loadCount > 1) {
response.setStatusLine(request.httpVersion, 404, "Not found");
return;
}
response.setStatusLine(request.httpVersion, 200, "OK");
response.write(`<!DOCTYPE html><script>console.log("200 page");</script>`);
}
);
add_task(async function testFailingHtmlSource() {
info("Test failure in retrieving html page sources");
// initDebuggerWithAbsoluteURL will first load the document once before the debugger,
// then the debugger will have to fetch the html page content via a network request
// therefore the test page will return a 404 error code on the second load attempt
const dbg = await initDebuggerWithAbsoluteURL(
BASE_URL + "200-then-404-page.html",
"200-then-404-page.html"
);
// We can't select the HTML page as its source content isn't fetched
// (waitForSelectedSource doesn't resolve)
// Note that it is important to load the page *before* opening the page
// so that the thread actor has to request the page content and will fail
const source = findSource(dbg, "200-then-404-page.html");
await dbg.actions.selectLocation(
getContext(dbg),
{ sourceId: source.id },
{ keepContext: false }
);
is(getCM(dbg).getValue(), `Error loading this URI: Unknown source`);
});
/**
* In this test we try to reproduce the "Loading..." message.
* This may happen when opening an HTML source that was loaded *before*
* opening DevTools. The thread actor will have to issue a new HTTP request
* to load the source content.
*/
let loadCount2 = 0;
let slowLoadingPageResolution = null;
httpServer.registerPathHandler(
"/slow-loading-page.html",
(request, response) => {
loadCount2++;
if (loadCount2 > 1) {
response.processAsync();
slowLoadingPageResolution = function() {
response.write(
`<!DOCTYPE html><script>console.log("slow-loading-page:second-load");</script>`
);
response.finish();
};
return;
}
response.write(
`<!DOCTYPE html><script>console.log("slow-loading-page:first-load");</script>`
);
}
);
add_task(async function testLoadingHtmlSource() {
info("Test loading progress of html page sources");
const dbg = await initDebuggerWithAbsoluteURL(
BASE_URL + "slow-loading-page.html",
"slow-loading-page.html"
);
const onSelected = selectSource(dbg, "slow-loading-page.html");
await waitFor(
() => getCM(dbg).getValue() == `Loading…`,
"Wait for the source to be displayed as loading"
);
info("Wait for a second HTTP request to be made for the html page");
await waitFor(
() => slowLoadingPageResolution,
"Wait for the html page to be queried a second time"
);
is(
getCM(dbg).getValue(),
`Loading…`,
"The source is still loading until we release the network request"
);
slowLoadingPageResolution();
info("Wait for the source to be fully selected and loaded");
await onSelected;
// Note that, even if the thread actor triggers a new HTTP request,
// it will use the HTTP cache and retrieve the first request content.
// This is actually relevant as that's the source that actually runs in the page!
//
// XXX Bug 1758458 - the source content is wrong.
// We should be seeing the whole HTML page content,
// whereas we only see the inline source text content.
is(getCM(dbg).getValue(), `console.log("slow-loading-page:first-load");`);
});