Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions web-ui/javascript/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ import {
} from "./actions";
import {generateFilename} from "./utils/packs";
import {
LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN
LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN,
safeGetItem,
safeSetItem
} from "./utils/storage";

import './App.css';
Expand Down Expand Up @@ -142,7 +144,7 @@ class App extends React.Component {
if (nextProps.evergreen.announce !== this.props.evergreen.announce && nextProps.evergreen.announce !== null) {
// Check last announce display time in local storage and compare to announce time
let announceTime = Date.parse(nextProps.evergreen.announce.date);
let lastAnnounceShown = localStorage.getItem(LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN) || 0;
let lastAnnounceShown = safeGetItem(LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN) || 0;
console.log('announce: ' + announceTime);
console.log('last shown: ' + lastAnnounceShown);
if (announceTime > lastAnnounceShown) {
Expand All @@ -167,7 +169,7 @@ class App extends React.Component {
};

dismissAnnounceDialog = () => {
localStorage.setItem(LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN, Date.now());
safeSetItem(LOCAL_STORAGE_ANNOUNCE_LAST_SHOWN, Date.now());
this.setState({announce: null});
};

Expand Down
47 changes: 47 additions & 0 deletions web-ui/javascript/src/components/ErrorBoundary.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/

import React from 'react';

/*
* The last line of defence around the application: React 16 unmounts the whole tree when a render
* error goes uncaught, which the user sees as a blank page with no way back. This shows what
* happened and offers a reload. It deliberately depends on nothing — no i18n, no store — since
* either of those may be what failed; the two sentences are given in both languages instead.
*/
class ErrorBoundary extends React.Component {

constructor(props) {
super(props);
this.state = { error: null };
}

static getDerivedStateFromError(error) {
return { error };
}

componentDidCatch(error, info) {
console.error('Unrecoverable render error', error, info && info.componentStack);
}

render() {
if (this.state.error) {
const message = (this.state.error && this.state.error.message) || String(this.state.error);
return (
<div className="error-boundary" style={{ padding: '2em', fontFamily: 'sans-serif' }}>
<h2>Something went wrong / Une erreur est survenue</h2>
<p>STUdio hit an error it could not recover from. Reloading the page is safe: nothing on the device is affected by a display error.</p>
<p>STUdio a rencontré une erreur dont il ne peut pas se remettre. Recharger la page est sans risque : rien sur l'appareil n'est affecté par une erreur d'affichage.</p>
<pre style={{ whiteSpace: 'pre-wrap' }}>{message}</pre>
<button onClick={() => window.location.reload()}>Reload / Recharger</button>
</div>
);
}
return this.props.children;
}
}

export default ErrorBoundary;
49 changes: 49 additions & 0 deletions web-ui/javascript/src/components/ErrorBoundary.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/

/*
* A render error anywhere used to unmount the whole tree: React 16 blanks the page when nothing
* catches. The boundary sits at the root and shows a message instead. It is rendered with
* react-dom into jsdom directly — the project has no component testing library and this does not
* add one.
*/

import React from 'react';
import ReactDOM from 'react-dom';
import ErrorBoundary from './ErrorBoundary';

function Bomb() {
throw new Error('boom');
}

describe('ErrorBoundary', () => {
let container;
let consoleError;

beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
// React reports the caught error on console.error; that is expected here, not noise to fail on.
consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterEach(() => {
ReactDOM.unmountComponentAtNode(container);
container.remove();
consoleError.mockRestore();
});

it('renders its children when nothing throws', () => {
ReactDOM.render(<ErrorBoundary><p>fine</p></ErrorBoundary>, container);
expect(container.textContent).toBe('fine');
});

it('shows a message instead of a blank page when a child throws', () => {
ReactDOM.render(<ErrorBoundary><Bomb /></ErrorBoundary>, container);
expect(container.querySelector('.error-boundary')).not.toBeNull();
expect(container.textContent).toContain('boom');
});
});
21 changes: 15 additions & 6 deletions web-ui/javascript/src/components/PackLibrary.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ import {
import {AppContext} from "../AppContext";
import Modal from "./Modal";
import {
LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT
LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT,
safeGetItem
} from "../utils/storage";
import {chooseDropAction, applyProvenanceVerdict} from "../utils/packs";
import {parseJson} from "../utils/json";
import {verifyConversion} from "../services/library";

import './PackLibrary.css';
Expand Down Expand Up @@ -87,7 +89,11 @@ class PackLibrary extends React.Component {
// Ignore missing node data
return;
}
var data = JSON.parse(packData);
var data = parseJson(packData);
if (!data) {
// A payload that is not JSON: not something this handler produced, so nothing to do
return;
}

const driverFormat = this.state.device.metadata.driver;
// Which artefact goes to the device is decided in utils/packs. It compares no timestamps and
Expand All @@ -113,7 +119,7 @@ class PackLibrary extends React.Component {
if (action === 'convert') { // No compatible pack: convert the source
console.log('pack must be converted for driver: %s', driverFormat);
// Ask for enriched raw format preference
if (driverFormat === 'raw' && localStorage.getItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === null) {
if (driverFormat === 'raw' && safeGetItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === null) {
this.setState({
allowEnrichedDialog: {
show: true,
Expand Down Expand Up @@ -278,8 +284,11 @@ class PackLibrary extends React.Component {
// Otherwise ignore missing node data / file
return;
}
var data = JSON.parse(packData);

var data = parseJson(packData);
if (!data) {
return;
}

// Transfer pack and show progress
this.props.addToLibrary(data.uuid, this.state.device.metadata.driver, this.context);
};
Expand Down Expand Up @@ -309,7 +318,7 @@ class PackLibrary extends React.Component {

onConvertLibraryPack = (pack, format) => {
return () => {
if (format === 'raw' && localStorage.getItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === null) {
if (format === 'raw' && safeGetItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === null) {
// Ask for enriched raw format preference
this.setState({
allowEnrichedDialog: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import TrayItemWidget from "./TrayItemWidget";
import Modal from "../../Modal";
import {setEditorDiagram, setEditorFilename, setDiagramErrors} from "../../../actions";
import {generateFilename} from "../../../utils/packs";
import {parseJson} from "../../../utils/json";


class PackDiagramWidget extends React.Component {
Expand Down Expand Up @@ -146,7 +147,10 @@ class PackDiagramWidget extends React.Component {
// Ignore missing node data
return;
}
var data = JSON.parse(nodeData);
var data = parseJson(nodeData);
if (!data) {
return;
}
var node = null;
switch (data.type) {
case "stage":
Expand Down
13 changes: 8 additions & 5 deletions web-ui/javascript/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createStore, applyMiddleware } from 'redux';
import thunkMiddleware from 'redux-thunk';

import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import rootReducer from './reducers';
import './i18n';

Expand All @@ -23,10 +24,12 @@ const store = createStore(
);

ReactDOM.render(
<Provider store={store}>
<Suspense fallback="Loading...">
<App />
</Suspense>
</Provider>,
<ErrorBoundary>
<Provider store={store}>
<Suspense fallback="Loading...">
<App />
</Suspense>
</Provider>
</ErrorBoundary>,
document.getElementById('root')
);
12 changes: 7 additions & 5 deletions web-ui/javascript/src/reducers/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,24 @@

import {
LOCAL_STORAGE_ANNOUNCE_OPTOUT,
LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT
LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT,
safeGetItem,
safeSetItem
} from "../utils/storage";


const initialState = {
announceOptOut: localStorage.getItem(LOCAL_STORAGE_ANNOUNCE_OPTOUT) === 'true' || false,
allowEnriched: localStorage.getItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === 'true' || false
announceOptOut: safeGetItem(LOCAL_STORAGE_ANNOUNCE_OPTOUT) === 'true',
allowEnriched: safeGetItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT) === 'true'
};

const settings = (state = initialState, action) => {
switch (action.type) {
case 'SET_ANNOUNCE_OPTOUT':
localStorage.setItem(LOCAL_STORAGE_ANNOUNCE_OPTOUT, action.announceOptOut);
safeSetItem(LOCAL_STORAGE_ANNOUNCE_OPTOUT, action.announceOptOut);
return { ...state, announceOptOut: action.announceOptOut };
case 'SET_ALLOW_ENRICHED':
localStorage.setItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT, action.allowEnriched);
safeSetItem(LOCAL_STORAGE_ALLOW_ENRICHED_BINARY_FORMAT, action.allowEnriched);
return { ...state, allowEnriched: action.allowEnriched };
default:
return state
Expand Down
10 changes: 8 additions & 2 deletions web-ui/javascript/src/services/library.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import {handleJsonOrError} from "../utils/fetch";
import {parseJson} from "../utils/json";

export const fetchLibraryInfos = () => {
return fetch('/api/library/infos')
Expand All @@ -31,8 +32,13 @@ export const uploadToLibrary = async (uuid, path, packData, progressHandler) =>
xhr.upload.onprogress = progressHandler;
}
xhr.onload = () => {
console.log('xhr upload complete: ' + JSON.parse(xhr.responseText));
resolve(JSON.parse(xhr.responseText));
let response = parseJson(xhr.responseText);
if (response === undefined) {
reject(new Error('Upload failed: unexpected response from the server (' + xhr.status + ')'));
return;
}
console.log('xhr upload complete');
resolve(response);
};
xhr.open('post', '/api/library/upload', true);
let formData = new FormData();
Expand Down
21 changes: 21 additions & 0 deletions web-ui/javascript/src/utils/json.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/

/*
* JSON.parse for input STUdio does not control: a drag-and-drop payload, an uploaded file, a
* response body. Anything that is not JSON answers undefined instead of throwing out of the
* handler that asked.
*/
export function parseJson(text) {
if (typeof text !== 'string' || text === '') {
return undefined;
}
try {
return JSON.parse(text);
} catch (e) {
return undefined;
}
}
20 changes: 20 additions & 0 deletions web-ui/javascript/src/utils/json.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/

const { parseJson } = require('./json');

describe('parseJson', () => {
it('parses valid JSON', () => {
expect(parseJson('{"a":1}')).toEqual({ a: 1 });
});

it('answers undefined for anything that is not JSON, instead of throwing', () => {
expect(parseJson('not json')).toBeUndefined();
expect(parseJson('')).toBeUndefined();
expect(parseJson(null)).toBeUndefined();
expect(parseJson(undefined)).toBeUndefined();
});
});
15 changes: 12 additions & 3 deletions web-ui/javascript/src/utils/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,25 @@ import CoverNodeModel from "../components/diagram/models/CoverNodeModel";
import MenuNodeModel from "../components/diagram/models/MenuNodeModel";
import StoryNodeModel from "../components/diagram/models/StoryNodeModel";
import PackDiagramModel from "../components/diagram/models/PackDiagramModel";
import {parseJson} from "./json";


export function readFromArchive(file) {
// Read zip archive
var zip = new JSZip();
return zip.loadAsync(file)
.then((archive) => {
// Read JSON story descriptor
return archive.file("story.json").async('string').then(storyJson => {
let json = JSON.parse(storyJson);
// Read JSON story descriptor. The archive is user input: say what is wrong with it
// rather than let a null or a SyntaxError escape from here.
let descriptor = archive.file("story.json");
if (!descriptor) {
throw new Error("Not a story pack archive: no story.json inside");
}
return descriptor.async('string').then(storyJson => {
let json = parseJson(storyJson);
if (!json) {
throw new Error("Not a story pack archive: story.json is not valid JSON");
}

var loadedModel = new PackDiagramModel(json.title, json.version, (json.description || ''), (json.nightModeAvailable || false));

Expand Down
30 changes: 30 additions & 0 deletions web-ui/javascript/src/utils/reader.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* 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 https://mozilla.org/MPL/2.0/.
*/

import JSZip from 'jszip';
import { readFromArchive } from './reader';

/*
* An uploaded archive is user input. A story.json that is not JSON used to surface as a raw
* SyntaxError from deep inside the promise chain; it is now a rejection that names the problem.
*/
describe('readFromArchive', () => {
it('rejects a story.json that is not JSON with a message that says so', async () => {
const zip = new JSZip();
zip.file('story.json', '{not json');
const blob = await zip.generateAsync({ type: 'blob' });

await expect(readFromArchive(blob)).rejects.toThrow(/story\.json/);
});

it('rejects an archive without story.json rather than throwing on null', async () => {
const zip = new JSZip();
zip.file('readme.txt', 'nothing here');
const blob = await zip.generateAsync({ type: 'blob' });

await expect(readFromArchive(blob)).rejects.toThrow(/story\.json/);
});
});
Loading
Loading