@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.
@@ -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 the duplicate glyph and hides the action during long-press delete mode",
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(resolve, 600);
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
- await page.evaluate(() => window.ScratchJr.clearSelection());
399
- await page.waitForFunction((currentPageId) => {
400
- const thumb = document.querySelector(`.pagethumb[data-owner="${currentPageId}"]`);
401
- const action = document.querySelector(
402
- `#pageactions .duplicatepage[data-owner="${currentPageId}"]`
403
- );
404
- return !thumb.classList.contains("shakeme")
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();