@robotical/martyblocksjr 4.2.8 → 4.2.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/editions/free/src/app.bundle.js +1 -1
- package/editions/free/src/css/editor.css +77 -49
- package/editions/free/src/css/editorleftpanel.css +44 -21
- package/editions/free/src/css/librarymodal.css +32 -0
- package/editions/free/src/css/thumbs.css +116 -14
- package/editions/free/src/settings.json +3 -0
- package/package.json +1 -1
- package/tests/CurriculumArtifact.test.js +272 -0
- package/tests/e2e/duplicate-page.e2e.test.js +46 -21
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const { importPackageMock } = vi.hoisted(() => ({
|
|
4
|
+
importPackageMock: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
|
|
7
|
+
vi.mock("@/editor/ProjectCloud", () => ({
|
|
8
|
+
default: {
|
|
9
|
+
importPackage: importPackageMock,
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
CURRICULUM_ARTIFACT_KIND,
|
|
15
|
+
CURRICULUM_ARTIFACT_PLATFORM,
|
|
16
|
+
importCurriculumArtifactFromSearch,
|
|
17
|
+
parseCurriculumArtifactRequest,
|
|
18
|
+
sha256Hex,
|
|
19
|
+
validateCurriculumArtifact,
|
|
20
|
+
} from "@/editor/CurriculumArtifact";
|
|
21
|
+
|
|
22
|
+
function validArtifact() {
|
|
23
|
+
return {
|
|
24
|
+
kind: CURRICULUM_ARTIFACT_KIND,
|
|
25
|
+
formatVersion: 1,
|
|
26
|
+
platform: CURRICULUM_ARTIFACT_PLATFORM,
|
|
27
|
+
projectPackage: {
|
|
28
|
+
formatVersion: 1,
|
|
29
|
+
project: {
|
|
30
|
+
id: "source-project-id-must-not-be-reused",
|
|
31
|
+
name: "Walking starter",
|
|
32
|
+
version: "iOSv01",
|
|
33
|
+
gallery: "samples",
|
|
34
|
+
isgift: "1",
|
|
35
|
+
thumbnail: {
|
|
36
|
+
pagecount: 1,
|
|
37
|
+
md5: "thumbnail.png",
|
|
38
|
+
},
|
|
39
|
+
json: {
|
|
40
|
+
pages: ["page 1"],
|
|
41
|
+
currentPage: "page 1",
|
|
42
|
+
"page 1": {
|
|
43
|
+
sprites: ["Marty 1"],
|
|
44
|
+
layers: ["Marty 1"],
|
|
45
|
+
md5: "Farm.svg",
|
|
46
|
+
"Marty 1": {
|
|
47
|
+
id: "Marty 1",
|
|
48
|
+
type: "sprite",
|
|
49
|
+
md5: "marty.svg",
|
|
50
|
+
animationFrames: ["marty.svg", "marty-2.svg"],
|
|
51
|
+
sounds: ["hello.wav"],
|
|
52
|
+
scripts: [],
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
assets: {
|
|
58
|
+
"thumbnail.png": "dGh1bWI=",
|
|
59
|
+
"marty.svg": "PHN2Zy8+",
|
|
60
|
+
"marty-2.svg": "PHN2Zy8+",
|
|
61
|
+
"hello.wav": "UklGRg==",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function responseFor(value, url = "https://cdn.sanity.io/files/project/dataset/walking.json") {
|
|
68
|
+
var bytes = new TextEncoder().encode(JSON.stringify(value));
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
url: url,
|
|
72
|
+
headers: {
|
|
73
|
+
get(name) {
|
|
74
|
+
if (name.toLowerCase() === "content-type") {
|
|
75
|
+
return "application/vnd.robotical.curriculum-code-artifact+json; charset=utf-8";
|
|
76
|
+
}
|
|
77
|
+
if (name.toLowerCase() === "content-length") {
|
|
78
|
+
return String(bytes.byteLength);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
arrayBuffer: async function () {
|
|
84
|
+
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("curriculum artifact import", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
importPackageMock.mockReset();
|
|
92
|
+
importPackageMock.mockResolvedValue({ projectId: "42" });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("normalizes a valid artifact into a fresh editable project package", () => {
|
|
96
|
+
var result = validateCurriculumArtifact(validArtifact());
|
|
97
|
+
|
|
98
|
+
expect(result.formatVersion).toBe(1);
|
|
99
|
+
expect(result.project).toMatchObject({
|
|
100
|
+
name: "Walking starter",
|
|
101
|
+
version: "iOSv01",
|
|
102
|
+
deleted: "NO",
|
|
103
|
+
isgift: "0",
|
|
104
|
+
});
|
|
105
|
+
expect(result.project.id).toBeUndefined();
|
|
106
|
+
expect(result.project.gallery).toBeUndefined();
|
|
107
|
+
expect(Object.keys(result.assets).sort()).toEqual([
|
|
108
|
+
"hello.wav",
|
|
109
|
+
"marty-2.svg",
|
|
110
|
+
"marty.svg",
|
|
111
|
+
"thumbnail.png",
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("rejects unsupported envelopes and packages", () => {
|
|
116
|
+
var artifact = validArtifact();
|
|
117
|
+
artifact.platform = "martyblocks";
|
|
118
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/platform/);
|
|
119
|
+
|
|
120
|
+
artifact = validArtifact();
|
|
121
|
+
artifact.formatVersion = 2;
|
|
122
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/formatVersion/);
|
|
123
|
+
|
|
124
|
+
artifact = validArtifact();
|
|
125
|
+
artifact.projectPackage.formatVersion = 2;
|
|
126
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/projectPackage formatVersion/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("rejects remote, traversal and unreferenced project assets", () => {
|
|
130
|
+
var artifact = validArtifact();
|
|
131
|
+
artifact.projectPackage.project.json["page 1"].md5 = "https://evil.example/background.svg";
|
|
132
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/safe relative asset identifier/);
|
|
133
|
+
|
|
134
|
+
artifact = validArtifact();
|
|
135
|
+
artifact.projectPackage.project.json["page 1"]["Marty 1"].md5 = "../marty.svg";
|
|
136
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/safe relative asset identifier/);
|
|
137
|
+
|
|
138
|
+
artifact = validArtifact();
|
|
139
|
+
artifact.projectPackage.assets["unused.svg"] = "PHN2Zy8+";
|
|
140
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/unreferenced asset/);
|
|
141
|
+
|
|
142
|
+
artifact = validArtifact();
|
|
143
|
+
artifact.projectPackage.project.json["page 1"]["Marty 1"].md5 = "sprites/%2e%2e/marty.svg";
|
|
144
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/safe relative asset identifier/);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("rejects project structures that the editor cannot safely recreate", () => {
|
|
148
|
+
var artifact = validArtifact();
|
|
149
|
+
delete artifact.projectPackage.project.json["page 1"].layers;
|
|
150
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/layers/);
|
|
151
|
+
|
|
152
|
+
artifact = validArtifact();
|
|
153
|
+
artifact.projectPackage.project.json["page 1"].layers = [];
|
|
154
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/every sprite/);
|
|
155
|
+
|
|
156
|
+
artifact = validArtifact();
|
|
157
|
+
delete artifact.projectPackage.project.json["page 1"]["Marty 1"].scripts;
|
|
158
|
+
expect(() => validateCurriculumArtifact(artifact)).toThrow(/scripts/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("accepts only configured HTTPS URLs and loopback development URLs", () => {
|
|
162
|
+
expect(parseCurriculumArtifactRequest(
|
|
163
|
+
"?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Fartifact.json" +
|
|
164
|
+
"&curriculumArtifactSha256=" + "a".repeat(64),
|
|
165
|
+
{
|
|
166
|
+
baseUrl: "https://app.example/blocks-jr",
|
|
167
|
+
allowedOrigins: ["https://cdn.sanity.io"],
|
|
168
|
+
}
|
|
169
|
+
).url).toBe("https://cdn.sanity.io/artifact.json");
|
|
170
|
+
|
|
171
|
+
expect(parseCurriculumArtifactRequest(
|
|
172
|
+
"?curriculumArtifactUrl=http%3A%2F%2Flocalhost%3A3011%2Fartifact.json",
|
|
173
|
+
{ baseUrl: "http://localhost:3011/blocks-jr" }
|
|
174
|
+
).url).toBe("http://localhost:3011/artifact.json");
|
|
175
|
+
|
|
176
|
+
expect(() => parseCurriculumArtifactRequest(
|
|
177
|
+
"?curriculumArtifactUrl=https%3A%2F%2Fevil.example%2Fartifact.json" +
|
|
178
|
+
"&curriculumArtifactSha256=" + "a".repeat(64),
|
|
179
|
+
{ baseUrl: "https://app.example/blocks-jr" }
|
|
180
|
+
)).toThrow(/origin is not allowed/);
|
|
181
|
+
|
|
182
|
+
expect(() => parseCurriculumArtifactRequest(
|
|
183
|
+
"?curriculumArtifactUrl=https%3A%2F%2Fapp.example%2Fartifact.json" +
|
|
184
|
+
"&curriculumArtifactSha256=" + "a".repeat(64),
|
|
185
|
+
{ baseUrl: "https://app.example/blocks-jr" }
|
|
186
|
+
)).toThrow(/origin is not allowed/);
|
|
187
|
+
|
|
188
|
+
expect(() => parseCurriculumArtifactRequest(
|
|
189
|
+
"?curriculumArtifactUrl=%2Fartifact.json&curriculumArtifactSha256=" + "a".repeat(64),
|
|
190
|
+
{ baseUrl: "https://app.example/blocks-jr" }
|
|
191
|
+
)).toThrow(/absolute URL/);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("allows explicitly configured artifact origins", () => {
|
|
195
|
+
var request = parseCurriculumArtifactRequest(
|
|
196
|
+
"?curriculumArtifactUrl=https%3A%2F%2Fcontent.robotical.io%2Fartifact.json" +
|
|
197
|
+
"&curriculumArtifactSha256=" + "a".repeat(64),
|
|
198
|
+
{
|
|
199
|
+
baseUrl: "https://app.example/blocks-jr",
|
|
200
|
+
allowedOrigins: ["https://content.robotical.io"],
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
expect(request.url).toBe("https://content.robotical.io/artifact.json");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("requires payload integrity outside loopback development", () => {
|
|
207
|
+
expect(() => parseCurriculumArtifactRequest(
|
|
208
|
+
"?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Fartifact.json",
|
|
209
|
+
{
|
|
210
|
+
baseUrl: "https://app.example/blocks-jr",
|
|
211
|
+
allowedOrigins: ["https://cdn.sanity.io"],
|
|
212
|
+
}
|
|
213
|
+
)).toThrow(/required outside local development/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("fetches, verifies and imports a package as a new local project", async () => {
|
|
217
|
+
var response = responseFor(validArtifact());
|
|
218
|
+
var body = await response.arrayBuffer();
|
|
219
|
+
var digest = sha256Hex(body);
|
|
220
|
+
var fetchMock = vi.fn().mockResolvedValue(response);
|
|
221
|
+
var result = await importCurriculumArtifactFromSearch(
|
|
222
|
+
"?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Ffiles%2Fproject%2Fdataset%2Fwalking.json" +
|
|
223
|
+
"&curriculumArtifactSha256=" + digest,
|
|
224
|
+
{
|
|
225
|
+
baseUrl: "https://app.example/blocks-jr",
|
|
226
|
+
allowedOrigins: ["https://cdn.sanity.io"],
|
|
227
|
+
fetchImplementation: fetchMock,
|
|
228
|
+
projectVersion: "iOSv01",
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(result).toEqual({ projectId: "42" });
|
|
233
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
234
|
+
"https://cdn.sanity.io/files/project/dataset/walking.json",
|
|
235
|
+
expect.objectContaining({
|
|
236
|
+
credentials: "omit",
|
|
237
|
+
cache: "no-store",
|
|
238
|
+
referrerPolicy: "no-referrer",
|
|
239
|
+
})
|
|
240
|
+
);
|
|
241
|
+
expect(importPackageMock).toHaveBeenCalledTimes(1);
|
|
242
|
+
expect(importPackageMock.mock.calls[0][0].project.id).toBeUndefined();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("does not write anything when the checksum is wrong", async () => {
|
|
246
|
+
var fetchMock = vi.fn().mockResolvedValue(responseFor(validArtifact()));
|
|
247
|
+
await expect(importCurriculumArtifactFromSearch(
|
|
248
|
+
"?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Ffiles%2Fproject%2Fdataset%2Fwalking.json" +
|
|
249
|
+
"&curriculumArtifactSha256=" + "0".repeat(64),
|
|
250
|
+
{
|
|
251
|
+
baseUrl: "https://app.example/blocks-jr",
|
|
252
|
+
allowedOrigins: ["https://cdn.sanity.io"],
|
|
253
|
+
fetchImplementation: fetchMock,
|
|
254
|
+
}
|
|
255
|
+
)).rejects.toThrow(/checksum/);
|
|
256
|
+
expect(importPackageMock).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("does not write anything when a redirect crosses an allowed-origin boundary", async () => {
|
|
260
|
+
var fetchMock = vi.fn().mockResolvedValue(responseFor(validArtifact(), "https://evil.example/artifact.json"));
|
|
261
|
+
await expect(importCurriculumArtifactFromSearch(
|
|
262
|
+
"?curriculumArtifactUrl=https%3A%2F%2Fcdn.sanity.io%2Ffiles%2Fproject%2Fdataset%2Fwalking.json" +
|
|
263
|
+
"&curriculumArtifactSha256=" + "b".repeat(64),
|
|
264
|
+
{
|
|
265
|
+
baseUrl: "https://app.example/blocks-jr",
|
|
266
|
+
allowedOrigins: ["https://cdn.sanity.io"],
|
|
267
|
+
fetchImplementation: fetchMock,
|
|
268
|
+
}
|
|
269
|
+
)).rejects.toThrow(/redirect origin/);
|
|
270
|
+
expect(importPackageMock).not.toHaveBeenCalled();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -340,7 +340,7 @@ describe("duplicate page", () => {
|
|
|
340
340
|
);
|
|
341
341
|
|
|
342
342
|
it(
|
|
343
|
-
"always shows
|
|
343
|
+
"always shows duplicate and bottom-right delete actions without long-press mode",
|
|
344
344
|
async () => {
|
|
345
345
|
const { browser, page, errors } = await openPage("/editor.html?mode=edit");
|
|
346
346
|
|
|
@@ -353,6 +353,12 @@ describe("duplicate page", () => {
|
|
|
353
353
|
const action = document.querySelector(
|
|
354
354
|
`#pageactions .duplicatepage[data-owner="${currentPageId}"]`
|
|
355
355
|
);
|
|
356
|
+
const deleteAction = document.querySelector(
|
|
357
|
+
`#pageactions .deletepage[data-owner="${currentPageId}"]`
|
|
358
|
+
);
|
|
359
|
+
const row = deleteAction.parentElement;
|
|
360
|
+
const deleteBounds = deleteAction.getBoundingClientRect();
|
|
361
|
+
const rowBounds = row.getBoundingClientRect();
|
|
356
362
|
const icon = action.querySelector(".duplicatepageicon");
|
|
357
363
|
const backSquare = window.getComputedStyle(icon, "::before");
|
|
358
364
|
const frontSquare = window.getComputedStyle(icon, "::after");
|
|
@@ -362,6 +368,10 @@ describe("duplicate page", () => {
|
|
|
362
368
|
frontContent: frontSquare.content,
|
|
363
369
|
backZIndex: backSquare.zIndex,
|
|
364
370
|
frontZIndex: frontSquare.zIndex,
|
|
371
|
+
deleteVisibility: window.getComputedStyle(deleteAction).visibility,
|
|
372
|
+
deleteEnabled: !deleteAction.disabled,
|
|
373
|
+
deleteAtBottomRight: Math.abs(deleteBounds.right - rowBounds.right) < 2
|
|
374
|
+
&& Math.abs(deleteBounds.bottom - rowBounds.bottom) < 2,
|
|
365
375
|
};
|
|
366
376
|
}, pageId);
|
|
367
377
|
|
|
@@ -370,8 +380,11 @@ describe("duplicate page", () => {
|
|
|
370
380
|
expect(restingState.frontContent).not.toBe("none");
|
|
371
381
|
expect(restingState.backZIndex).not.toBe("-1");
|
|
372
382
|
expect(restingState.frontZIndex).not.toBe("-1");
|
|
383
|
+
expect(restingState.deleteVisibility).toBe("visible");
|
|
384
|
+
expect(restingState.deleteEnabled).toBe(true);
|
|
385
|
+
expect(restingState.deleteAtBottomRight).toBe(true);
|
|
373
386
|
|
|
374
|
-
await page.evaluate((currentPageId) => new Promise((resolve) => {
|
|
387
|
+
const heldState = await page.evaluate((currentPageId) => new Promise((resolve) => {
|
|
375
388
|
const thumb = document.querySelector(`.pagethumb[data-owner="${currentPageId}"]`);
|
|
376
389
|
const bounds = thumb.getBoundingClientRect();
|
|
377
390
|
thumb.dispatchEvent(new PointerEvent("pointerdown", {
|
|
@@ -383,27 +396,39 @@ describe("duplicate page", () => {
|
|
|
383
396
|
pointerType: "mouse",
|
|
384
397
|
isPrimary: true,
|
|
385
398
|
}));
|
|
386
|
-
window.setTimeout(
|
|
399
|
+
window.setTimeout(() => {
|
|
400
|
+
const duplicateAction = document.querySelector(
|
|
401
|
+
`#pageactions .duplicatepage[data-owner="${currentPageId}"]`
|
|
402
|
+
);
|
|
403
|
+
const deleteAction = document.querySelector(
|
|
404
|
+
`#pageactions .deletepage[data-owner="${currentPageId}"]`
|
|
405
|
+
);
|
|
406
|
+
const result = {
|
|
407
|
+
isShaking: thumb.classList.contains("shakeme"),
|
|
408
|
+
hasLegacyDeleteControl: Boolean(thumb.querySelector(".deletethumb")),
|
|
409
|
+
duplicateVisibility: window.getComputedStyle(duplicateAction).visibility,
|
|
410
|
+
deleteVisibility: window.getComputedStyle(deleteAction).visibility,
|
|
411
|
+
};
|
|
412
|
+
window.dispatchEvent(new PointerEvent("pointerup", {
|
|
413
|
+
bubbles: true,
|
|
414
|
+
cancelable: true,
|
|
415
|
+
clientX: bounds.left + (bounds.width / 2),
|
|
416
|
+
clientY: bounds.top + (bounds.height / 2),
|
|
417
|
+
pointerId: 1,
|
|
418
|
+
pointerType: "mouse",
|
|
419
|
+
isPrimary: true,
|
|
420
|
+
}));
|
|
421
|
+
resolve(result);
|
|
422
|
+
}, 600);
|
|
387
423
|
}), pageId);
|
|
388
|
-
await page.waitForFunction((currentPageId) => {
|
|
389
|
-
const thumb = document.querySelector(`.pagethumb[data-owner="${currentPageId}"]`);
|
|
390
|
-
const action = document.querySelector(
|
|
391
|
-
`#pageactions .duplicatepage[data-owner="${currentPageId}"]`
|
|
392
|
-
);
|
|
393
|
-
return thumb.classList.contains("shakeme")
|
|
394
|
-
&& window.getComputedStyle(action).visibility === "hidden"
|
|
395
|
-
&& window.getComputedStyle(thumb.querySelector(".deletethumb")).visibility === "visible";
|
|
396
|
-
}, { timeout: 5_000 }, pageId);
|
|
397
424
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
&& window.getComputedStyle(action).visibility === "visible";
|
|
406
|
-
}, { timeout: 5_000 }, pageId);
|
|
425
|
+
expect(heldState.isShaking).toBe(false);
|
|
426
|
+
expect(heldState.hasLegacyDeleteControl).toBe(false);
|
|
427
|
+
expect(heldState.duplicateVisibility).toBe("visible");
|
|
428
|
+
expect(heldState.deleteVisibility).toBe("visible");
|
|
429
|
+
|
|
430
|
+
await page.click(`#pageactions .deletepage[data-owner="${pageId}"]`);
|
|
431
|
+
await page.waitForFunction(() => window.ScratchJr.stage.pages.length === 1);
|
|
407
432
|
expect(errors).toEqual([]);
|
|
408
433
|
} finally {
|
|
409
434
|
await browser.close();
|