From a6a07ca9b60c3e1859792927762e1828cd1dc11c Mon Sep 17 00:00:00 2001 From: Yasser Elsayed Date: Mon, 4 Mar 2019 16:53:01 -0800 Subject: [PATCH] Add markdown viewer (#897) * working, needs tests * tests * add output artifact loader tests * cleanup new experiment tests * pr comments --- .../mock-backend/model-output/metadata.json | 14 +- frontend/package-lock.json | 302 ++++++--------- frontend/package.json | 1 + frontend/src/CSSReset.tsx | 1 - .../viewers/MarkdownViewer.test.tsx | 66 ++++ .../src/components/viewers/MarkdownViewer.tsx | 51 +++ frontend/src/components/viewers/Viewer.ts | 1 + .../components/viewers/ViewerContainer.tsx | 2 + .../MarkdownViewer.test.tsx.snap | 58 +++ .../ViewerContainer.test.tsx.snap | 12 + frontend/src/lib/OutputArtifactLoader.test.ts | 47 ++- frontend/src/lib/OutputArtifactLoader.ts | 23 +- frontend/src/pages/NewExperiment.test.tsx | 48 +-- .../src/pages/RecurringRunDetails.test.tsx | 75 ++-- .../src/pages/RecurringRunsManager.test.tsx | 30 +- .../RecurringRunsManager.test.tsx.snap | 352 +++++++++--------- 16 files changed, 628 insertions(+), 455 deletions(-) create mode 100644 frontend/src/components/viewers/MarkdownViewer.test.tsx create mode 100644 frontend/src/components/viewers/MarkdownViewer.tsx create mode 100644 frontend/src/components/viewers/__snapshots__/MarkdownViewer.test.tsx.snap diff --git a/frontend/mock-backend/model-output/metadata.json b/frontend/mock-backend/model-output/metadata.json index 8098e2e7795..310351cca14 100644 --- a/frontend/mock-backend/model-output/metadata.json +++ b/frontend/mock-backend/model-output/metadata.json @@ -1,5 +1,11 @@ { - "outputs": [{ + "outputs": [ + { + "storage": "inline", + "source": "# Title\n[some link here](http://example.com)", + "type": "markdown" + }, + { "format": "csv", "labels": [ "rec.sport.hockey", @@ -40,11 +46,13 @@ "name": "count" } ] - }, { + }, + { "storage": "gcs", "source": "gs://some/path/hello-world.html", "type": "web-app" - }, { + }, + { "storage": "gcs", "source": "gs://some/path/hello-world-big.html", "type": "web-app" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7a27f4045a4..699bfb9188e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -123,14 +123,6 @@ "react-is": "^16.6.3" } }, - "@pleasetrythisathome/react.animate": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@pleasetrythisathome/react.animate/-/react.animate-0.0.4.tgz", - "integrity": "sha1-R5d7ecXP70GZJhrCTpvQvpv26Ic=", - "requires": { - "ease-component": "^1.0.0" - } - }, "@types/body-parser": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", @@ -3322,16 +3314,6 @@ "sha.js": "^2.4.8" } }, - "create-react-class": { - "version": "15.6.3", - "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.3.tgz", - "integrity": "sha512-M+/3Q6E6DLO6Yx3OwrWjwHBnvfXXYA7W+dFjt/ZDBemHO1DDZhsalX/NUtnTYclN6GfnBDRh4qRHjcDHmlJBJg==", - "requires": { - "fbjs": "^0.8.9", - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" - } - }, "cross-spawn": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", @@ -4310,11 +4292,6 @@ "xtend": "^4.0.0" } }, - "ease-component": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ease-component/-/ease-component-1.0.0.tgz", - "integrity": "sha1-s3VybbC1sEWVt3RAOW/sfapdd8k=" - }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -5066,27 +5043,6 @@ "ua-parser-js": "^0.7.18" } }, - "fbp": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/fbp/-/fbp-1.7.0.tgz", - "integrity": "sha1-cZN6z85Ny6KafFNKacL9gtB2BxQ=" - }, - "fbp-graph": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/fbp-graph/-/fbp-graph-0.4.0.tgz", - "integrity": "sha512-u2KWjZdrnUjvbyLvJ8Lyinz+7bxHD2FIwJnY9axQcFwvdiRg5jpmS2eJTvU5hYrMMbDiSHoXdBeA3hyPl9ZDaA==", - "requires": { - "clone": "^2.1.0", - "fbp": "^1.6.0" - }, - "dependencies": { - "clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" - } - } - }, "fd-slicer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", @@ -5236,11 +5192,6 @@ "debug": "=3.1.0" } }, - "font-awesome": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", - "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=" - }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -5386,13 +5337,11 @@ }, "balanced-match": { "version": "1.0.0", - "bundled": true, - "optional": true + "bundled": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -5405,18 +5354,15 @@ }, "code-point-at": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "concat-map": { "version": "0.0.1", - "bundled": true, - "optional": true + "bundled": true }, "console-control-strings": { "version": "1.1.0", - "bundled": true, - "optional": true + "bundled": true }, "core-util-is": { "version": "1.0.2", @@ -5519,8 +5465,7 @@ }, "inherits": { "version": "2.0.3", - "bundled": true, - "optional": true + "bundled": true }, "ini": { "version": "1.3.5", @@ -5530,7 +5475,6 @@ "is-fullwidth-code-point": { "version": "1.0.0", "bundled": true, - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -5543,20 +5487,17 @@ "minimatch": { "version": "3.0.4", "bundled": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } }, "minimist": { "version": "0.0.8", - "bundled": true, - "optional": true + "bundled": true }, "minipass": { "version": "2.2.4", "bundled": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -5573,7 +5514,6 @@ "mkdirp": { "version": "0.5.1", "bundled": true, - "optional": true, "requires": { "minimist": "0.0.8" } @@ -5646,8 +5586,7 @@ }, "number-is-nan": { "version": "1.0.1", - "bundled": true, - "optional": true + "bundled": true }, "object-assign": { "version": "4.1.1", @@ -5657,7 +5596,6 @@ "once": { "version": "1.4.0", "bundled": true, - "optional": true, "requires": { "wrappy": "1" } @@ -5763,7 +5701,6 @@ "string-width": { "version": "1.0.2", "bundled": true, - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -6053,11 +5990,6 @@ "duplexer": "^0.1.1" } }, - "hammerjs": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", - "integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=" - }, "handle-thing": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-1.2.5.tgz", @@ -7175,7 +7107,7 @@ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", "requires": { - "arr-flatten": "1.1.0" + "arr-flatten": "^1.0.1" } }, "array-unique": { @@ -7188,9 +7120,9 @@ "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", "requires": { - "expand-range": "1.8.2", - "preserve": "0.2.0", - "repeat-element": "1.1.3" + "expand-range": "^1.8.1", + "preserve": "^0.2.0", + "repeat-element": "^1.1.2" } }, "chalk": { @@ -7208,7 +7140,7 @@ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", "requires": { - "is-posix-bracket": "0.1.1" + "is-posix-bracket": "^0.1.0" } }, "extglob": { @@ -7216,7 +7148,7 @@ "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", "requires": { - "is-extglob": "1.0.0" + "is-extglob": "^1.0.0" } }, "is-extglob": { @@ -7229,7 +7161,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", "requires": { - "is-extglob": "1.0.0" + "is-extglob": "^1.0.0" } }, "jest-cli": { @@ -7278,7 +7210,7 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "requires": { - "is-buffer": "1.1.6" + "is-buffer": "^1.1.5" } }, "micromatch": { @@ -7286,19 +7218,19 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", "requires": { - "arr-diff": "2.0.0", - "array-unique": "0.2.1", - "braces": "1.8.5", - "expand-brackets": "0.1.5", - "extglob": "0.3.2", - "filename-regex": "2.0.1", - "is-extglob": "1.0.0", - "is-glob": "2.0.1", - "kind-of": "3.2.2", - "normalize-path": "2.1.1", - "object.omit": "2.0.1", - "parse-glob": "3.0.4", - "regex-cache": "0.4.4" + "arr-diff": "^2.0.0", + "array-unique": "^0.2.1", + "braces": "^1.8.2", + "expand-brackets": "^0.1.4", + "extglob": "^0.3.1", + "filename-regex": "^2.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.1", + "kind-of": "^3.0.2", + "normalize-path": "^2.0.1", + "object.omit": "^2.0.0", + "parse-glob": "^3.0.4", + "regex-cache": "^0.4.2" } }, "strip-ansi": { @@ -7306,7 +7238,7 @@ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", "requires": { - "ansi-regex": "3.0.0" + "ansi-regex": "^3.0.0" } } } @@ -7423,7 +7355,7 @@ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", "requires": { - "arr-flatten": "1.1.0" + "arr-flatten": "^1.0.1" } }, "array-unique": { @@ -7436,9 +7368,9 @@ "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", "requires": { - "expand-range": "1.8.2", - "preserve": "0.2.0", - "repeat-element": "1.1.3" + "expand-range": "^1.8.1", + "preserve": "^0.2.0", + "repeat-element": "^1.1.2" } }, "expand-brackets": { @@ -7446,7 +7378,7 @@ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", "requires": { - "is-posix-bracket": "0.1.1" + "is-posix-bracket": "^0.1.0" } }, "extglob": { @@ -7454,7 +7386,7 @@ "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", "requires": { - "is-extglob": "1.0.0" + "is-extglob": "^1.0.0" } }, "is-extglob": { @@ -7467,7 +7399,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", "requires": { - "is-extglob": "1.0.0" + "is-extglob": "^1.0.0" } }, "kind-of": { @@ -7475,7 +7407,7 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "requires": { - "is-buffer": "1.1.6" + "is-buffer": "^1.1.5" } }, "micromatch": { @@ -7483,19 +7415,19 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", "requires": { - "arr-diff": "2.0.0", - "array-unique": "0.2.1", - "braces": "1.8.5", - "expand-brackets": "0.1.5", - "extglob": "0.3.2", - "filename-regex": "2.0.1", - "is-extglob": "1.0.0", - "is-glob": "2.0.1", - "kind-of": "3.2.2", - "normalize-path": "2.1.1", - "object.omit": "2.0.1", - "parse-glob": "3.0.4", - "regex-cache": "0.4.4" + "arr-diff": "^2.0.0", + "array-unique": "^0.2.1", + "braces": "^1.8.2", + "expand-brackets": "^0.1.4", + "extglob": "^0.3.1", + "filename-regex": "^2.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.1", + "kind-of": "^3.0.2", + "normalize-path": "^2.0.1", + "object.omit": "^2.0.0", + "parse-glob": "^3.0.4", + "regex-cache": "^0.4.2" } } } @@ -7577,7 +7509,7 @@ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", "requires": { - "arr-flatten": "1.1.0" + "arr-flatten": "^1.0.1" } }, "array-unique": { @@ -7590,9 +7522,9 @@ "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", "requires": { - "expand-range": "1.8.2", - "preserve": "0.2.0", - "repeat-element": "1.1.3" + "expand-range": "^1.8.1", + "preserve": "^0.2.0", + "repeat-element": "^1.1.2" } }, "chalk": { @@ -7610,7 +7542,7 @@ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", "requires": { - "is-posix-bracket": "0.1.1" + "is-posix-bracket": "^0.1.0" } }, "extglob": { @@ -7618,7 +7550,7 @@ "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", "requires": { - "is-extglob": "1.0.0" + "is-extglob": "^1.0.0" } }, "is-extglob": { @@ -7631,7 +7563,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", "requires": { - "is-extglob": "1.0.0" + "is-extglob": "^1.0.0" } }, "kind-of": { @@ -7639,7 +7571,7 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "requires": { - "is-buffer": "1.1.6" + "is-buffer": "^1.1.5" } }, "micromatch": { @@ -7647,19 +7579,19 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", "requires": { - "arr-diff": "2.0.0", - "array-unique": "0.2.1", - "braces": "1.8.5", - "expand-brackets": "0.1.5", - "extglob": "0.3.2", - "filename-regex": "2.0.1", - "is-extglob": "1.0.0", - "is-glob": "2.0.1", - "kind-of": "3.2.2", - "normalize-path": "2.1.1", - "object.omit": "2.0.1", - "parse-glob": "3.0.4", - "regex-cache": "0.4.4" + "arr-diff": "^2.0.0", + "array-unique": "^0.2.1", + "braces": "^1.8.2", + "expand-brackets": "^0.1.4", + "extglob": "^0.3.1", + "filename-regex": "^2.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.1", + "kind-of": "^3.0.2", + "normalize-path": "^2.0.1", + "object.omit": "^2.0.0", + "parse-glob": "^3.0.4", + "regex-cache": "^0.4.2" } } } @@ -7753,7 +7685,7 @@ "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-2.0.0.tgz", "integrity": "sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=", "requires": { - "arr-flatten": "1.1.0" + "arr-flatten": "^1.0.1" } }, "array-unique": { @@ -7766,9 +7698,9 @@ "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", "integrity": "sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=", "requires": { - "expand-range": "1.8.2", - "preserve": "0.2.0", - "repeat-element": "1.1.3" + "expand-range": "^1.8.1", + "preserve": "^0.2.0", + "repeat-element": "^1.1.2" } }, "chalk": { @@ -7786,7 +7718,7 @@ "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-0.1.5.tgz", "integrity": "sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=", "requires": { - "is-posix-bracket": "0.1.1" + "is-posix-bracket": "^0.1.0" } }, "extglob": { @@ -7794,7 +7726,7 @@ "resolved": "https://registry.npmjs.org/extglob/-/extglob-0.3.2.tgz", "integrity": "sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=", "requires": { - "is-extglob": "1.0.0" + "is-extglob": "^1.0.0" } }, "is-extglob": { @@ -7807,7 +7739,7 @@ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", "integrity": "sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=", "requires": { - "is-extglob": "1.0.0" + "is-extglob": "^1.0.0" } }, "kind-of": { @@ -7815,7 +7747,7 @@ "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", "requires": { - "is-buffer": "1.1.6" + "is-buffer": "^1.1.5" } }, "micromatch": { @@ -7823,19 +7755,19 @@ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", "integrity": "sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=", "requires": { - "arr-diff": "2.0.0", - "array-unique": "0.2.1", - "braces": "1.8.5", - "expand-brackets": "0.1.5", - "extglob": "0.3.2", - "filename-regex": "2.0.1", - "is-extglob": "1.0.0", - "is-glob": "2.0.1", - "kind-of": "3.2.2", - "normalize-path": "2.1.1", - "object.omit": "2.0.1", - "parse-glob": "3.0.4", - "regex-cache": "0.4.4" + "arr-diff": "^2.0.0", + "array-unique": "^0.2.1", + "braces": "^1.8.2", + "expand-brackets": "^0.1.4", + "extglob": "^0.3.1", + "filename-regex": "^2.0.0", + "is-extglob": "^1.0.0", + "is-glob": "^2.0.1", + "kind-of": "^3.0.2", + "normalize-path": "^2.0.1", + "object.omit": "^2.0.0", + "parse-glob": "^3.0.4", + "regex-cache": "^0.4.2" } }, "strip-bom": { @@ -8227,19 +8159,6 @@ "graceful-fs": "^4.1.9" } }, - "klayjs": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/klayjs/-/klayjs-0.2.1.tgz", - "integrity": "sha1-rLDvCmB8C86rAuhQGkK3WzFQZyA=" - }, - "klayjs-noflo": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/klayjs-noflo/-/klayjs-noflo-0.3.1.tgz", - "integrity": "sha1-CS/lXMKJgFWTUDveUAG2pe3bSV8=", - "requires": { - "klayjs": "^0.2.1" - } - }, "latest-version": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz", @@ -8307,6 +8226,14 @@ } } }, + "linkify-it": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.1.0.tgz", + "integrity": "sha512-4REs8/062kV2DSHxNfq5183zrqXMl7WP0WzABH9IeJI+NLm429FgE1PDecltYfnOoFDFlZGh2T8PfZn0r+GTRg==", + "requires": { + "uc.micro": "^1.0.1" + } + }, "load-bmfont": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.0.tgz", @@ -8667,6 +8594,18 @@ "object-visit": "^1.0.0" } }, + "markdown-it": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.2.tgz", + "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==", + "requires": { + "argparse": "^1.0.7", + "entities": "~1.1.1", + "linkify-it": "^2.0.0", + "mdurl": "^1.0.1", + "uc.micro": "^1.0.5" + } + }, "marked": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/marked/-/marked-0.5.2.tgz", @@ -8725,6 +8664,11 @@ "safe-buffer": "^5.1.2" } }, + "mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=" + }, "media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -12392,7 +12336,7 @@ "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.10.0.tgz", "integrity": "sha1-TkkvjQTftviQA1B/btvy1QHnxvQ=", "requires": { - "websocket-driver": "0.7.0" + "websocket-driver": ">=0.5.1" } }, "uuid": { @@ -13473,11 +13417,6 @@ "safe-buffer": "^5.0.1" } }, - "tv4": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", - "integrity": "sha1-0CDIRvrdUMhVq7JeuuzGj8EPeWM=" - }, "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -13525,6 +13464,11 @@ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.19.tgz", "integrity": "sha512-T3PVJ6uz8i0HzPxOF9SWzWAlfN/DavlpQqepn22xgve/5QecC+XMCAtmUNnY7C9StehaV6exjUCI801lOI7QlQ==" }, + "uc.micro": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", + "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + }, "uglify-js": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.4.9.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5919d6a52bd..46ff947b7e3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "immer": "^1.7.4", "js-yaml": "^3.12.0", "lodash": ">=4.17.11", + "markdown-it": "^8.4.2", "portable-fetch": "^3.0.0", "re-resizable": "^4.9.0", "react": "^16.7.0", diff --git a/frontend/src/CSSReset.tsx b/frontend/src/CSSReset.tsx index 50a7d6ba71a..173ae2f3457 100644 --- a/frontend/src/CSSReset.tsx +++ b/frontend/src/CSSReset.tsx @@ -34,7 +34,6 @@ menu, nav, output, ruby, section, summary, time, mark, audio, video { border: 0; /* Consider adding flex-shrink: 0; */ - font-size: 100%; margin: 0; padding: 0; vertical-align: baseline; diff --git a/frontend/src/components/viewers/MarkdownViewer.test.tsx b/frontend/src/components/viewers/MarkdownViewer.test.tsx new file mode 100644 index 00000000000..8006a3859a7 --- /dev/null +++ b/frontend/src/components/viewers/MarkdownViewer.test.tsx @@ -0,0 +1,66 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import MarkdownViewer, { MarkdownViewerConfig } from './MarkdownViewer'; +import { PlotType } from './Viewer'; + +describe('MarkdownViewer', () => { + let tree: ReactWrapper | null; + + afterEach(() => { + if (!tree) { + return; + } + tree.unmount(); + tree = null; + }); + + it('does not break on empty data', () => { + tree = mount(); + expect(tree).toMatchSnapshot(); + }); + + it('renders some basic markdown', () => { + const markdown = '# Title\n[some link here](http://example.com)'; + const config: MarkdownViewerConfig = { + markdownContent: markdown, + type: PlotType.MARKDOWN, + }; + + tree = mount(); + expect(tree).toMatchSnapshot(); + }); + + it('sanitizes the markdown to remove XSS', () => { + const markdown = ` + lower[click me](javascript:...)lower + upper[click me](javascript:...)upper + `; + const config: MarkdownViewerConfig = { + markdownContent: markdown, + type: PlotType.MARKDOWN, + }; + + tree = mount(); + expect(tree).toMatchSnapshot(); + }); + + it('returns a user friendly display name', () => { + expect(MarkdownViewer.prototype.getDisplayName()).toBe('Markdown'); + }); +}); diff --git a/frontend/src/components/viewers/MarkdownViewer.tsx b/frontend/src/components/viewers/MarkdownViewer.tsx new file mode 100644 index 00000000000..fb2a22cbd7b --- /dev/null +++ b/frontend/src/components/viewers/MarkdownViewer.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as React from 'react'; +import Viewer, { ViewerConfig } from './Viewer'; +// tslint:disable-next-line:no-var-requires +const markdownIt = require('markdown-it'); + +export interface MarkdownViewerConfig extends ViewerConfig { + markdownContent: string; +} + +interface MarkdownViewerProps { + configs: MarkdownViewerConfig[]; + maxDimension?: number; +} + +class MarkdownViewer extends Viewer { + private _config = this.props.configs[0]; + + constructor(props: any) { + super(props); + } + + public getDisplayName(): string { + return 'Markdown'; + } + + public render(): JSX.Element | null { + if (!this._config) { + return null; + } + const html = markdownIt().render(this._config.markdownContent); + return
; + } +} + +export default MarkdownViewer; diff --git a/frontend/src/components/viewers/Viewer.ts b/frontend/src/components/viewers/Viewer.ts index ca33bd34dec..2a9470649f3 100644 --- a/frontend/src/components/viewers/Viewer.ts +++ b/frontend/src/components/viewers/Viewer.ts @@ -18,6 +18,7 @@ import * as React from 'react'; export enum PlotType { CONFUSION_MATRIX = 'confusion_matrix', + MARKDOWN = 'markdown', ROC = 'roc', TABLE = 'table', TENSORBOARD = 'tensorboard', diff --git a/frontend/src/components/viewers/ViewerContainer.tsx b/frontend/src/components/viewers/ViewerContainer.tsx index fd0f22510e5..050594a6144 100644 --- a/frontend/src/components/viewers/ViewerContainer.tsx +++ b/frontend/src/components/viewers/ViewerContainer.tsx @@ -17,6 +17,7 @@ import * as React from 'react'; import ConfusionMatrix from './ConfusionMatrix'; import HTMLViewer from './HTMLViewer'; +import MarkdownViewer from './MarkdownViewer'; import PagedTable from './PagedTable'; import ROCCurve from './ROCCurve'; import TensorboardViewer from './Tensorboard'; @@ -24,6 +25,7 @@ import { PlotType, ViewerConfig } from './Viewer'; export const componentMap = { [PlotType.CONFUSION_MATRIX]: ConfusionMatrix, + [PlotType.MARKDOWN]: MarkdownViewer, [PlotType.ROC]: ROCCurve, [PlotType.TABLE]: PagedTable, [PlotType.TENSORBOARD]: TensorboardViewer, diff --git a/frontend/src/components/viewers/__snapshots__/MarkdownViewer.test.tsx.snap b/frontend/src/components/viewers/__snapshots__/MarkdownViewer.test.tsx.snap new file mode 100644 index 00000000000..68de71e0911 --- /dev/null +++ b/frontend/src/components/viewers/__snapshots__/MarkdownViewer.test.tsx.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MarkdownViewer does not break on empty data 1`] = ` + +`; + +exports[`MarkdownViewer renders some basic markdown 1`] = ` + +
Title +

some link here

+", + } + } + /> + +`; + +exports[`MarkdownViewer sanitizes the markdown to remove XSS 1`] = ` + +
lower[click me](javascript&#x3a;...)lower + upper[click me](javascript&#X3a;...)upper + +", + } + } + /> + +`; diff --git a/frontend/src/components/viewers/__snapshots__/ViewerContainer.test.tsx.snap b/frontend/src/components/viewers/__snapshots__/ViewerContainer.test.tsx.snap index 4f7935fe1ef..6d0114536b0 100644 --- a/frontend/src/components/viewers/__snapshots__/ViewerContainer.test.tsx.snap +++ b/frontend/src/components/viewers/__snapshots__/ViewerContainer.test.tsx.snap @@ -14,6 +14,18 @@ exports[`ViewerContainer renders a viewer of type CONFUSION_MATRIX 1`] = ` /> `; +exports[`ViewerContainer renders a viewer of type MARKDOWN 1`] = ` + +`; + exports[`ViewerContainer renders a viewer of type ROC 1`] = ` { const storagePath: StoragePath = { bucket: 'b', key: 'k', source: StorageService.GCS }; @@ -243,6 +244,44 @@ describe('OutputArtifactLoader', () => { }); }); + describe('buildMarkdownViewerConfig', () => { + it('requires "source" metadata field', () => { + const metadata = { header: 'header', format: 'format' }; + expect(OutputArtifactLoader.buildMarkdownViewerConfig(metadata as any)).rejects.toThrowError( + 'Malformed metadata, property "source" is required.'); + }); + + it('returns a markdown viewer config with basic metadata for inline markdown', async () => { + const metadata = { source: '# some markdown here', storage: 'inline' }; + expect(await OutputArtifactLoader.buildMarkdownViewerConfig(metadata as any)).toEqual({ + markdownContent: '# some markdown here', + type: PlotType.MARKDOWN, + } as MarkdownViewerConfig); + }); + + it('returns a markdown viewer config with basic metadata for gcs path markdown', async () => { + const metadata = { source: 'gs://path', storage: 'gcs' }; + fileToRead = ` + Hello World! + `; + expect(await OutputArtifactLoader.buildMarkdownViewerConfig(metadata as any)).toEqual({ + markdownContent: fileToRead, + type: PlotType.MARKDOWN, + } as MarkdownViewerConfig); + }); + + it('assumes remote path by default, and returns a markdown viewer config with basic metadata', async () => { + const metadata = { source: 'gs://path' }; + fileToRead = ` + Hello World! + `; + expect(await OutputArtifactLoader.buildMarkdownViewerConfig(metadata as any)).toEqual({ + markdownContent: fileToRead, + type: PlotType.MARKDOWN, + } as MarkdownViewerConfig); + }); + }); + describe('buildRocCurveConfig', () => { it('requires "source" metadata field', () => { const metadata = { header: 'header', schema: 'schema' }; diff --git a/frontend/src/lib/OutputArtifactLoader.ts b/frontend/src/lib/OutputArtifactLoader.ts index 9b0e34b6f5c..b45dcc153f1 100644 --- a/frontend/src/lib/OutputArtifactLoader.ts +++ b/frontend/src/lib/OutputArtifactLoader.ts @@ -18,6 +18,7 @@ import WorkflowParser, { StoragePath } from './WorkflowParser'; import { Apis } from '../lib/Apis'; import { ConfusionMatrixConfig } from '../components/viewers/ConfusionMatrix'; import { HTMLViewerConfig } from '../components/viewers/HTMLViewer'; +import { MarkdownViewerConfig } from '../components/viewers/MarkdownViewer'; import { PagedTableConfig } from '../components/viewers/PagedTable'; import { PlotType, ViewerConfig } from '../components/viewers/Viewer'; import { ROCCurveConfig } from '../components/viewers/ROCCurve'; @@ -32,7 +33,7 @@ export interface PlotMetadata { predicted_col?: string; schema?: Array<{ type: string, name: string }>; source: string; - storage?: 'gcs'; + storage?: 'gcs' | 'inline'; target_col?: string; type: PlotType; } @@ -69,6 +70,8 @@ export class OutputArtifactLoader { switch (metadata.type) { case (PlotType.CONFUSION_MATRIX): return await this.buildConfusionMatrixConfig(metadata); + case (PlotType.MARKDOWN): + return await this.buildMarkdownViewerConfig(metadata); case (PlotType.TABLE): return await this.buildPagedTableConfig(metadata); case (PlotType.TENSORBOARD): @@ -191,6 +194,24 @@ export class OutputArtifactLoader { }; } + public static async buildMarkdownViewerConfig(metadata: PlotMetadata): Promise { + if (!metadata.source) { + throw new Error('Malformed metadata, property "source" is required.'); + } + let markdownContent = ''; + if (metadata.storage === 'inline') { + markdownContent = metadata.source; + } else { + const path = WorkflowParser.parseStoragePath(metadata.source); + markdownContent = await Apis.readFile(path); + } + + return { + markdownContent, + type: PlotType.MARKDOWN, + }; + } + public static async buildRocCurveConfig(metadata: PlotMetadata): Promise { if (!metadata.source) { throw new Error('Malformed metadata, property "source" is required.'); diff --git a/frontend/src/pages/NewExperiment.test.tsx b/frontend/src/pages/NewExperiment.test.tsx index 66375f9a21e..b185f190312 100644 --- a/frontend/src/pages/NewExperiment.test.tsx +++ b/frontend/src/pages/NewExperiment.test.tsx @@ -17,12 +17,13 @@ import * as React from 'react'; import NewExperiment from './NewExperiment'; import TestUtils from '../TestUtils'; -import { shallow } from 'enzyme'; +import { shallow, ReactWrapper, ShallowWrapper } from 'enzyme'; import { PageProps } from './Page'; import { Apis } from '../lib/Apis'; import { RoutePage, QUERY_PARAMS } from '../components/Router'; describe('NewExperiment', () => { + let tree: ReactWrapper | ShallowWrapper; const createExperimentSpy = jest.spyOn(Apis.experimentServiceApi, 'createExperiment'); const historyPushSpy = jest.fn(); const updateDialogSpy = jest.fn(); @@ -53,37 +54,35 @@ describe('NewExperiment', () => { createExperimentSpy.mockImplementation(() => ({ id: 'new-experiment-id' })); }); - it('renders the new experiment page', () => { - const tree = shallow(); + afterEach(() => tree.unmount()); + it('renders the new experiment page', () => { + tree = shallow(); expect(tree).toMatchSnapshot(); - tree.unmount(); }); it('does not include any action buttons in the toolbar', () => { - const tree = shallow(); + tree = shallow(); expect(updateToolbarSpy).toHaveBeenCalledWith({ actions: [], breadcrumbs: [{ displayName: 'Experiments', href: RoutePage.EXPERIMENTS }], pageTitle: 'New experiment', }); - tree.unmount(); }); it('enables the \'Next\' button when an experiment name is entered', () => { - const tree = shallow(); + tree = shallow(); expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', true); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment name' } }); expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', false); expect(tree).toMatchSnapshot(); - tree.unmount(); }); it('re-disables the \'Next\' button when an experiment name is cleared after having been entered', () => { - const tree = shallow(); + tree = shallow(); expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', true); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment name' } }); @@ -92,11 +91,10 @@ describe('NewExperiment', () => { (tree.instance() as any).handleChange('experimentName')({ target: { value: '' } }); expect(tree.find('#createExperimentBtn').props()).toHaveProperty('disabled', true); expect(tree).toMatchSnapshot(); - tree.unmount(); }); it('updates the experiment name', () => { - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment name' } }); expect(tree.state()).toEqual({ @@ -105,11 +103,10 @@ describe('NewExperiment', () => { isbeingCreated: false, validationError: '', }); - tree.unmount(); }); it('updates the experiment description', () => { - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('description')({ target: { value: 'a description!' } }); expect(tree.state()).toEqual({ @@ -118,11 +115,10 @@ describe('NewExperiment', () => { isbeingCreated: false, validationError: 'Experiment name is required', }); - tree.unmount(); }); it('sets the page to a busy state upon clicking \'Next\'', async () => { - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment-name' } }); @@ -131,11 +127,10 @@ describe('NewExperiment', () => { expect(tree.state()).toHaveProperty('isbeingCreated', true); expect(tree.find('#createExperimentBtn').props()).toHaveProperty('busy', true); - tree.unmount(); }); it('calls the createExperiment API with the new experiment upon clicking \'Next\'', async () => { - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment name' } }); (tree.instance() as any).handleChange('description')({ target: { value: 'experiment description' } }); @@ -147,13 +142,12 @@ describe('NewExperiment', () => { description: 'experiment description', name: 'experiment name', }); - tree.unmount(); }); it('navigates to NewRun page upon successful creation', async () => { const experimentId = 'test-exp-id-1'; createExperimentSpy.mockImplementation(() => ({ id: experimentId })); - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment-name' } }); @@ -165,7 +159,6 @@ describe('NewExperiment', () => { RoutePage.NEW_RUN + `?experimentId=${experimentId}` + `&firstRunInExperiment=1`); - tree.unmount(); }); it('includes pipeline ID in NewRun page query params if present', async () => { @@ -175,7 +168,7 @@ describe('NewExperiment', () => { const pipelineId = 'some-pipeline-id'; const props = generateProps(); props.location.search = `?${QUERY_PARAMS.pipelineId}=${pipelineId}`; - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment-name' } }); @@ -188,11 +181,10 @@ describe('NewExperiment', () => { + `?experimentId=${experimentId}` + `&pipelineId=${pipelineId}` + `&firstRunInExperiment=1`); - tree.unmount(); }); it('shows snackbar confirmation after experiment is created', async () => { - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment-name' } }); @@ -204,7 +196,6 @@ describe('NewExperiment', () => { message: 'Successfully created new Experiment: experiment-name', open: true, }); - tree.unmount(); }); it('unsets busy state when creation fails', async () => { @@ -212,7 +203,7 @@ describe('NewExperiment', () => { // tslint:disable-next-line:no-console console.error = jest.spyOn(console, 'error').mockImplementation(); - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment-name' } }); @@ -222,7 +213,6 @@ describe('NewExperiment', () => { await TestUtils.flushPromises(); expect(tree.state()).toHaveProperty('isbeingCreated', false); - tree.unmount(); }); it('shows error dialog when creation fails', async () => { @@ -230,7 +220,7 @@ describe('NewExperiment', () => { // tslint:disable-next-line:no-console console.error = jest.spyOn(console, 'error').mockImplementation(); - const tree = shallow(); + tree = shallow(); (tree.instance() as any).handleChange('experimentName')({ target: { value: 'experiment-name' } }); @@ -242,15 +232,13 @@ describe('NewExperiment', () => { const call = updateDialogSpy.mock.calls[0][0]; expect(call).toHaveProperty('title', 'Experiment creation failed'); expect(call).toHaveProperty('content', 'test error!'); - tree.unmount(); }); it('navigates to experiment list page upon cancellation', async () => { - const tree = shallow(); + tree = shallow(); tree.find('#cancelNewExperimentBtn').simulate('click'); await TestUtils.flushPromises(); expect(historyPushSpy).toHaveBeenCalledWith(RoutePage.EXPERIMENTS); - tree.unmount(); }); }); diff --git a/frontend/src/pages/RecurringRunDetails.test.tsx b/frontend/src/pages/RecurringRunDetails.test.tsx index 2dfb3879bbf..2c4c651afaf 100644 --- a/frontend/src/pages/RecurringRunDetails.test.tsx +++ b/frontend/src/pages/RecurringRunDetails.test.tsx @@ -22,9 +22,11 @@ import { Apis } from '../lib/Apis'; import { PageProps } from './Page'; import { RouteParams, RoutePage } from '../components/Router'; import { ToolbarActionConfig } from '../components/Toolbar'; -import { shallow } from 'enzyme'; +import { shallow, ReactWrapper, ShallowWrapper } from 'enzyme'; describe('RecurringRunDetails', () => { + let tree: ReactWrapper | ShallowWrapper; + const updateBannerSpy = jest.fn(); const updateDialogSpy = jest.fn(); const updateSnackbarSpy = jest.fn(); @@ -82,11 +84,12 @@ describe('RecurringRunDetails', () => { getExperimentSpy.mockClear(); }); + afterEach(() => tree.unmount()); + it('renders a recurring run with periodic schedule', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); - tree.unmount(); }); it('renders a recurring run with cron schedule', async () => { @@ -97,45 +100,41 @@ describe('RecurringRunDetails', () => { start_time: new Date(2018, 9, 8, 7, 6), } }; - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(tree).toMatchSnapshot(); - tree.unmount(); }); it('loads the recurring run given its id in query params', async () => { // The run id is in the router match object, defined inside generateProps - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(getJobSpy).toHaveBeenLastCalledWith(fullTestJob.id); expect(getExperimentSpy).not.toHaveBeenCalled(); - tree.unmount(); }); it('shows All runs -> run name when there is no experiment', async () => { // The run id is in the router match object, defined inside generateProps - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(updateToolbarSpy).toHaveBeenLastCalledWith(expect.objectContaining({ breadcrumbs: [{ displayName: 'All runs', href: RoutePage.RUNS }], pageTitle: fullTestJob.name, })); - tree.unmount(); }); it('loads the recurring run and its experiment if it has one', async () => { fullTestJob.resource_references = [{ key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT } }]; - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(getJobSpy).toHaveBeenLastCalledWith(fullTestJob.id); expect(getExperimentSpy).toHaveBeenLastCalledWith('test-experiment-id'); - tree.unmount(); }); it('shows Experiments -> Experiment name -> run name when there is an experiment', async () => { fullTestJob.resource_references = [{ key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT } }]; getExperimentSpy.mockImplementation(id => ({ id, name: 'test experiment name' })); - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(updateToolbarSpy).toHaveBeenLastCalledWith(expect.objectContaining({ breadcrumbs: [ @@ -148,12 +147,11 @@ describe('RecurringRunDetails', () => { ], pageTitle: fullTestJob.name, })); - tree.unmount(); }); it('shows error banner if run cannot be fetched', async () => { TestUtils.makeErrorResponseOnce(getJobSpy, 'woops!'); - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear, once to show error expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -161,13 +159,12 @@ describe('RecurringRunDetails', () => { message: `Error: failed to retrieve recurring run: ${fullTestJob.id}. Click Details for more information.`, mode: 'error', })); - tree.unmount(); }); it('shows warning banner if has experiment but experiment cannot be fetched. still loads run', async () => { fullTestJob.resource_references = [{ key: { id: 'test-experiment-id', type: ApiResourceType.EXPERIMENT } }]; TestUtils.makeErrorResponseOnce(getExperimentSpy, 'woops!'); - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(updateBannerSpy).toHaveBeenCalledTimes(2); // Once to clear, once to show error expect(updateBannerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -176,22 +173,20 @@ describe('RecurringRunDetails', () => { mode: 'warning', })); expect(tree.state('run')).toEqual(fullTestJob); - tree.unmount(); }); it('has a Refresh button, clicking it refreshes the run details', async () => { - const tree = shallow(); + tree = shallow(); const instance = tree.instance() as RecurringRunDetails; const refreshBtn = instance.getInitialToolbarState().actions.find(b => b.title === 'Refresh'); expect(refreshBtn).toBeDefined(); expect(getJobSpy).toHaveBeenCalledTimes(1); await refreshBtn!.action(); expect(getJobSpy).toHaveBeenCalledTimes(2); - tree.unmount(); }); it('shows enabled Disable, and disabled Enable buttons if the run is enabled', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(updateToolbarSpy).toHaveBeenCalledTimes(2); const lastToolbarButtons = updateToolbarSpy.mock.calls[1][0].actions as ToolbarActionConfig[]; @@ -201,12 +196,11 @@ describe('RecurringRunDetails', () => { const disableBtn = lastToolbarButtons.find(b => b.title === 'Disable'); expect(disableBtn).toBeDefined(); expect(disableBtn!.disabled).toBe(false); - tree.unmount(); }); it('shows enabled Disable, and disabled Enable buttons if the run is disabled', async () => { fullTestJob.enabled = false; - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(updateToolbarSpy).toHaveBeenCalledTimes(2); const lastToolbarButtons = updateToolbarSpy.mock.calls[1][0].actions as ToolbarActionConfig[]; @@ -216,12 +210,11 @@ describe('RecurringRunDetails', () => { const disableBtn = lastToolbarButtons.find(b => b.title === 'Disable'); expect(disableBtn).toBeDefined(); expect(disableBtn!.disabled).toBe(true); - tree.unmount(); }); it('shows enabled Disable, and disabled Enable buttons if the run is undefined', async () => { fullTestJob.enabled = undefined; - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); expect(updateToolbarSpy).toHaveBeenCalledTimes(2); const lastToolbarButtons = updateToolbarSpy.mock.calls[1][0].actions as ToolbarActionConfig[]; @@ -231,11 +224,10 @@ describe('RecurringRunDetails', () => { const disableBtn = lastToolbarButtons.find(b => b.title === 'Disable'); expect(disableBtn).toBeDefined(); expect(disableBtn!.disabled).toBe(true); - tree.unmount(); }); it('calls disable API when disable button is clicked, refreshes the page', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const instance = tree.instance() as RecurringRunDetails; const disableBtn = instance.getInitialToolbarState().actions.find(b => b.title === 'Disable'); @@ -244,11 +236,10 @@ describe('RecurringRunDetails', () => { expect(disableJobSpy).toHaveBeenLastCalledWith('test-job-id'); expect(getJobSpy).toHaveBeenCalledTimes(2); expect(getJobSpy).toHaveBeenLastCalledWith('test-job-id'); - tree.unmount(); }); it('shows error dialog if disable fails', async () => { - const tree = shallow(); + tree = shallow(); TestUtils.makeErrorResponseOnce(disableJobSpy, 'could not disable'); await TestUtils.flushPromises(); const instance = tree.instance() as RecurringRunDetails; @@ -259,12 +250,11 @@ describe('RecurringRunDetails', () => { content: 'could not disable', title: 'Failed to disable recurring run', })); - tree.unmount(); }); it('shows error dialog if enable fails', async () => { fullTestJob.enabled = false; - const tree = shallow(); + tree = shallow(); TestUtils.makeErrorResponseOnce(enableJobSpy, 'could not enable'); await TestUtils.flushPromises(); const instance = tree.instance() as RecurringRunDetails; @@ -275,12 +265,11 @@ describe('RecurringRunDetails', () => { content: 'could not enable', title: 'Failed to enable recurring run', })); - tree.unmount(); }); it('calls enable API when enable button is clicked, refreshes the page', async () => { fullTestJob.enabled = false; - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const instance = tree.instance() as RecurringRunDetails; const enableBtn = instance.getInitialToolbarState().actions.find(b => b.title === 'Enable'); @@ -289,20 +278,18 @@ describe('RecurringRunDetails', () => { expect(enableJobSpy).toHaveBeenLastCalledWith('test-job-id'); expect(getJobSpy).toHaveBeenCalledTimes(2); expect(getJobSpy).toHaveBeenLastCalledWith('test-job-id'); - tree.unmount(); }); it('shows a delete button', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const instance = tree.instance() as RecurringRunDetails; const deleteBtn = instance.getInitialToolbarState().actions.find(b => b.title === 'Refresh'); expect(deleteBtn).toBeDefined(); - tree.unmount(); }); it('shows delete dialog when delete button is clicked', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const instance = tree.instance() as RecurringRunDetails; const deleteBtn = instance.getInitialToolbarState().actions.find(b => b.title === 'Delete'); @@ -310,11 +297,10 @@ describe('RecurringRunDetails', () => { expect(updateDialogSpy).toHaveBeenLastCalledWith(expect.objectContaining({ title: 'Delete this recurring run config?', })); - tree.unmount(); }); it('calls delete API when delete confirmation dialog button is clicked', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const instance = tree.instance() as RecurringRunDetails; const deleteBtn = instance.getInitialToolbarState().actions.find(b => b.title === 'Delete'); @@ -324,11 +310,10 @@ describe('RecurringRunDetails', () => { await confirmBtn.onClick(); expect(deleteJobSpy).toHaveBeenCalledTimes(1); expect(deleteJobSpy).toHaveBeenLastCalledWith('test-job-id'); - tree.unmount(); }); it('does not call delete API when delete cancel dialog button is clicked', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const instance = tree.instance() as RecurringRunDetails; const deleteBtn = instance.getInitialToolbarState().actions.find(b => b.title === 'Delete'); @@ -339,14 +324,13 @@ describe('RecurringRunDetails', () => { expect(deleteJobSpy).not.toHaveBeenCalled(); // Should not reroute expect(historyPushSpy).not.toHaveBeenCalled(); - tree.unmount(); }); // TODO: need to test the dismiss path too--when the dialog is dismissed using ESC // or clicking outside it, it should be treated the same way as clicking Cancel. it('redirects back to parent experiment after delete', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const deleteBtn = (tree.instance() as RecurringRunDetails) .getInitialToolbarState().actions.find(b => b.title === 'Delete'); @@ -357,11 +341,10 @@ describe('RecurringRunDetails', () => { expect(deleteJobSpy).toHaveBeenLastCalledWith('test-job-id'); expect(historyPushSpy).toHaveBeenCalledTimes(1); expect(historyPushSpy).toHaveBeenLastCalledWith(RoutePage.EXPERIMENTS); - tree.unmount(); }); it('shows snackbar after successful deletion', async () => { - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const deleteBtn = (tree.instance() as RecurringRunDetails) .getInitialToolbarState().actions.find(b => b.title === 'Delete'); @@ -374,12 +357,11 @@ describe('RecurringRunDetails', () => { message: 'Delete succeeded for this recurring run config', open: true, }); - tree.unmount(); }); it('shows error dialog after failing deletion', async () => { TestUtils.makeErrorResponseOnce(deleteJobSpy, 'could not delete'); - const tree = shallow(); + tree = shallow(); await TestUtils.flushPromises(); const deleteBtn = (tree.instance() as RecurringRunDetails) .getInitialToolbarState().actions.find(b => b.title === 'Delete'); @@ -395,7 +377,6 @@ describe('RecurringRunDetails', () => { })); // Should not reroute expect(historyPushSpy).not.toHaveBeenCalled(); - tree.unmount(); }); }); diff --git a/frontend/src/pages/RecurringRunsManager.test.tsx b/frontend/src/pages/RecurringRunsManager.test.tsx index aab19483e49..eaec671f820 100644 --- a/frontend/src/pages/RecurringRunsManager.test.tsx +++ b/frontend/src/pages/RecurringRunsManager.test.tsx @@ -17,7 +17,7 @@ import * as React from 'react'; import TestUtils from '../TestUtils'; import { ListRequest, Apis } from '../lib/Apis'; -import { shallow } from 'enzyme'; +import { shallow, ReactWrapper, ShallowWrapper } from 'enzyme'; import RecurringRunsManager, { RecurringRunListProps } from './RecurringRunsManager'; import { ApiJob, ApiResourceType } from '../apis/job'; @@ -31,6 +31,8 @@ describe('RecurringRunsManager', () => { } } + let tree: ReactWrapper | ShallowWrapper; + const updateDialogSpy = jest.fn(); const updateSnackbarSpy = jest.fn(); const listJobsSpy = jest.spyOn(Apis.jobServiceApi, 'listJobs'); @@ -74,8 +76,10 @@ describe('RecurringRunsManager', () => { updateSnackbarSpy.mockReset(); }); + afterEach(() => tree.unmount()); + it('calls API to load recurring runs', async () => { - const tree = shallow(); + tree = shallow(); await (tree.instance() as TestRecurringRunsManager)._loadRuns({}); expect(listJobsSpy).toHaveBeenCalledTimes(1); expect(listJobsSpy).toHaveBeenLastCalledWith( @@ -87,13 +91,12 @@ describe('RecurringRunsManager', () => { undefined); expect(tree.state('runs')).toEqual(JOBS); expect(tree).toMatchSnapshot(); - tree.unmount(); }); it('shows error dialog if listing fails', async () => { TestUtils.makeErrorResponseOnce(listJobsSpy, 'woops!'); jest.spyOn(console, 'error').mockImplementation(); - const tree = shallow(); + tree = shallow(); await (tree.instance() as TestRecurringRunsManager)._loadRuns({}); expect(listJobsSpy).toHaveBeenCalledTimes(1); expect(updateDialogSpy).toHaveBeenLastCalledWith(expect.objectContaining({ @@ -101,25 +104,24 @@ describe('RecurringRunsManager', () => { title: 'Error retrieving recurring run configs', })); expect(tree.state('runs')).toEqual([]); - tree.unmount(); }); it('calls API to enable run', async () => { - const tree = shallow(); + tree = shallow(); await (tree.instance() as TestRecurringRunsManager)._setEnabledState('test-run', true); expect(enableJobSpy).toHaveBeenCalledTimes(1); expect(enableJobSpy).toHaveBeenLastCalledWith('test-run'); }); it('calls API to disable run', async () => { - const tree = shallow(); + tree = shallow(); await (tree.instance() as TestRecurringRunsManager)._setEnabledState('test-run', false); expect(disableJobSpy).toHaveBeenCalledTimes(1); expect(disableJobSpy).toHaveBeenLastCalledWith('test-run'); }); it('shows error if enable API call fails', async () => { - const tree = shallow(); + tree = shallow(); TestUtils.makeErrorResponseOnce(enableJobSpy, 'cannot enable'); await (tree.instance() as TestRecurringRunsManager)._setEnabledState('test-run', true); expect(updateDialogSpy).toHaveBeenCalledTimes(1); @@ -130,7 +132,7 @@ describe('RecurringRunsManager', () => { }); it('shows error if disable API call fails', async () => { - const tree = shallow(); + tree = shallow(); TestUtils.makeErrorResponseOnce(disableJobSpy, 'cannot disable'); await (tree.instance() as TestRecurringRunsManager)._setEnabledState('test-run', false); expect(updateDialogSpy).toHaveBeenCalledTimes(1); @@ -141,12 +143,12 @@ describe('RecurringRunsManager', () => { }); it('renders run name as link to its details page', () => { - const tree = TestUtils.mountWithRouter(); + tree = TestUtils.mountWithRouter(); expect((tree.instance() as RecurringRunsManager)._nameCustomRenderer({ value: 'test-run', id: 'run-id' })).toMatchSnapshot(); }); it('renders a disable button if the run is enabled, clicking the button calls disable API', async () => { - const tree = TestUtils.mountWithRouter(); + tree = TestUtils.mountWithRouter(); await TestUtils.flushPromises(); tree.update(); @@ -160,7 +162,7 @@ describe('RecurringRunsManager', () => { }); it('renders an enable button if the run is disabled, clicking the button calls enable API', async () => { - const tree = TestUtils.mountWithRouter(); + tree = TestUtils.mountWithRouter(); await TestUtils.flushPromises(); tree.update(); @@ -174,7 +176,7 @@ describe('RecurringRunsManager', () => { }); it('renders an enable button if the run\'s enabled field is undefined, clicking the button calls enable API', async () => { - const tree = TestUtils.mountWithRouter(); + tree = TestUtils.mountWithRouter(); await TestUtils.flushPromises(); tree.update(); @@ -188,7 +190,7 @@ describe('RecurringRunsManager', () => { }); it('reloads the list of runs after enable/disabling', async () => { - const tree = TestUtils.mountWithRouter(); + tree = TestUtils.mountWithRouter(); await TestUtils.flushPromises(); tree.update(); diff --git a/frontend/src/pages/__snapshots__/RecurringRunsManager.test.tsx.snap b/frontend/src/pages/__snapshots__/RecurringRunsManager.test.tsx.snap index 464f329ad3a..8e7f65f8210 100644 --- a/frontend/src/pages/__snapshots__/RecurringRunsManager.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/RecurringRunsManager.test.tsx.snap @@ -75,32 +75,32 @@ exports[`RecurringRunsManager reloads the list of runs after enable/disabling 1` className="root" classes={ Object { - "colorInherit": "MuiButton-colorInherit-152", - "contained": "MuiButton-contained-142", - "containedPrimary": "MuiButton-containedPrimary-143", - "containedSecondary": "MuiButton-containedSecondary-144", - "disabled": "MuiButton-disabled-151", - "extendedFab": "MuiButton-extendedFab-149", - "fab": "MuiButton-fab-148", - "flat": "MuiButton-flat-136", - "flatPrimary": "MuiButton-flatPrimary-137", - "flatSecondary": "MuiButton-flatSecondary-138", - "focusVisible": "MuiButton-focusVisible-150", - "fullWidth": "MuiButton-fullWidth-156", - "label": "MuiButton-label-132", - "mini": "MuiButton-mini-153", - "outlined": "MuiButton-outlined-139", - "outlinedPrimary": "MuiButton-outlinedPrimary-140", - "outlinedSecondary": "MuiButton-outlinedSecondary-141", - "raised": "MuiButton-raised-145", - "raisedPrimary": "MuiButton-raisedPrimary-146", - "raisedSecondary": "MuiButton-raisedSecondary-147", - "root": "MuiButton-root-131", - "sizeLarge": "MuiButton-sizeLarge-155", - "sizeSmall": "MuiButton-sizeSmall-154", - "text": "MuiButton-text-133", - "textPrimary": "MuiButton-textPrimary-134", - "textSecondary": "MuiButton-textSecondary-135", + "colorInherit": "MuiButton-colorInherit-836", + "contained": "MuiButton-contained-826", + "containedPrimary": "MuiButton-containedPrimary-827", + "containedSecondary": "MuiButton-containedSecondary-828", + "disabled": "MuiButton-disabled-835", + "extendedFab": "MuiButton-extendedFab-833", + "fab": "MuiButton-fab-832", + "flat": "MuiButton-flat-820", + "flatPrimary": "MuiButton-flatPrimary-821", + "flatSecondary": "MuiButton-flatSecondary-822", + "focusVisible": "MuiButton-focusVisible-834", + "fullWidth": "MuiButton-fullWidth-840", + "label": "MuiButton-label-816", + "mini": "MuiButton-mini-837", + "outlined": "MuiButton-outlined-823", + "outlinedPrimary": "MuiButton-outlinedPrimary-824", + "outlinedSecondary": "MuiButton-outlinedSecondary-825", + "raised": "MuiButton-raised-829", + "raisedPrimary": "MuiButton-raisedPrimary-830", + "raisedSecondary": "MuiButton-raisedSecondary-831", + "root": "MuiButton-root-815", + "sizeLarge": "MuiButton-sizeLarge-839", + "sizeSmall": "MuiButton-sizeSmall-838", + "text": "MuiButton-text-817", + "textPrimary": "MuiButton-textPrimary-818", + "textSecondary": "MuiButton-textSecondary-819", } } color="primary" @@ -115,22 +115,22 @@ exports[`RecurringRunsManager reloads the list of runs after enable/disabling 1` variant="text" >