@sasjs/cli 6.30.0 → 6.30.1
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/build/commands/build/internal/mockSite/assets/boot.js +23 -1
- package/build/commands/build/internal/mockSite/assets/persist.js +76 -5
- package/build/commands/build/internal/mockSite/assets/runtime-core.js +413 -50
- package/build/commands/build/internal/mockSite/assets/sw.js +128 -11
- package/build/commands/build/internal/mockSite/mockSite.d.ts +23 -0
- package/build/commands/build/internal/mockSite/mockSite.js +238 -23
- package/build/commands/build/internal/mockSite/spec/boot.spec.js +50 -3
- package/build/commands/build/internal/mockSite/spec/mockSite.spec.js +131 -3
- package/build/commands/build/internal/mockSite/spec/persist.spec.js +179 -1
- package/build/commands/build/internal/mockSite/spec/runtime-core.spec.js +546 -0
- package/build/commands/build/internal/mockSite/spec/sw.spec.js +308 -4
- package/package.json +1 -1
|
@@ -147,7 +147,11 @@ var loadWorker = function (base, options) {
|
|
|
147
147
|
}
|
|
148
148
|
};
|
|
149
149
|
global.self = self;
|
|
150
|
+
// what the worker pulled into itself, so a test can assert that a script it
|
|
151
|
+
// does not need at boot is not fetched at boot
|
|
152
|
+
var imported = [];
|
|
150
153
|
global.importScripts = function (path) {
|
|
154
|
+
imported.push(path);
|
|
151
155
|
if (path.includes('md5'))
|
|
152
156
|
return;
|
|
153
157
|
if (path.includes('api-browser'))
|
|
@@ -185,7 +189,7 @@ var loadWorker = function (base, options) {
|
|
|
185
189
|
};
|
|
186
190
|
require('../assets/sw.js');
|
|
187
191
|
global.fetch = jest.fn(function (input) { return __awaiter(void 0, void 0, void 0, function () {
|
|
188
|
-
var target, fixture, fixture;
|
|
192
|
+
var target, fixture, fixture, shards, piece, id, requested, shards_1;
|
|
189
193
|
var _a;
|
|
190
194
|
return __generator(this, function (_b) {
|
|
191
195
|
target = typeof input === 'string' ? input : (input && input.url) || String(input);
|
|
@@ -222,7 +226,29 @@ var loadWorker = function (base, options) {
|
|
|
222
226
|
userId: mockSite_1.WEBOUT_SENTINELS.userId,
|
|
223
227
|
apiServerUrl: mockSite_1.WEBOUT_SENTINELS.apiServerUrl
|
|
224
228
|
})
|
|
225
|
-
})
|
|
229
|
+
})
|
|
230
|
+
// a sharded drive, when a test sets one up: the index answers the snapshot
|
|
231
|
+
// URL and each piece answers its own file, so the worker's fetch of a shard
|
|
232
|
+
// is the same request it makes against a static host
|
|
233
|
+
];
|
|
234
|
+
shards = global.__sasjsShards;
|
|
235
|
+
if (shards && target.includes('vfs-snapshot')) {
|
|
236
|
+
piece = /vfs-snapshot-(\d+)\.json/.exec(target);
|
|
237
|
+
if (piece) {
|
|
238
|
+
id = Number(piece[1]);
|
|
239
|
+
requested = (global.__sasjsShardFetches =
|
|
240
|
+
global.__sasjsShardFetches || []);
|
|
241
|
+
requested.push(id);
|
|
242
|
+
return [2 /*return*/, jsonResponse({ format: mockSite_1.DRIVE_FORMAT, files: shards[id] || {} })];
|
|
243
|
+
}
|
|
244
|
+
return [2 /*return*/, jsonResponse(shards.index)];
|
|
245
|
+
}
|
|
246
|
+
if (target.includes('mock-config')) {
|
|
247
|
+
shards_1 = global.__sasjsShards;
|
|
248
|
+
return [2 /*return*/, jsonResponse(__assign({ appLoc: appLoc }, (shards_1
|
|
249
|
+
? { snapshotShards: (shards_1.index.shards || []).length }
|
|
250
|
+
: {})))];
|
|
251
|
+
}
|
|
226
252
|
return [2 /*return*/, target.includes('vfs-seed')
|
|
227
253
|
? jsonResponse({
|
|
228
254
|
files: (_a = {},
|
|
@@ -266,6 +292,7 @@ var loadWorker = function (base, options) {
|
|
|
266
292
|
dispatch: dispatch,
|
|
267
293
|
message: message,
|
|
268
294
|
lifecycle: lifecycle,
|
|
295
|
+
imported: imported,
|
|
269
296
|
self: self,
|
|
270
297
|
refuseWrites: function (value) {
|
|
271
298
|
refusingWrites = value;
|
|
@@ -308,6 +335,8 @@ describe('service worker host', function () {
|
|
|
308
335
|
delete global.importScripts;
|
|
309
336
|
delete global.fetch;
|
|
310
337
|
delete global.__sasjsIdentity;
|
|
338
|
+
delete global.__sasjsShards;
|
|
339
|
+
delete global.__sasjsShardFetches;
|
|
311
340
|
delete global.__mockHostClients;
|
|
312
341
|
});
|
|
313
342
|
var seeded = function () {
|
|
@@ -1412,6 +1441,161 @@ describe('service worker host', function () {
|
|
|
1412
1441
|
}
|
|
1413
1442
|
});
|
|
1414
1443
|
}); });
|
|
1444
|
+
/**
|
|
1445
|
+
* The linter is not part of the install.
|
|
1446
|
+
*
|
|
1447
|
+
* The page cannot make its first request until the worker has installed, so
|
|
1448
|
+
* anything the worker imports at boot is on the critical path of every
|
|
1449
|
+
* visit. The linter answers two routes; it is fetched when one of them is
|
|
1450
|
+
* called.
|
|
1451
|
+
*/
|
|
1452
|
+
it('fetches the linter on the first code route, not at boot', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1453
|
+
var worker, response;
|
|
1454
|
+
return __generator(this, function (_a) {
|
|
1455
|
+
switch (_a.label) {
|
|
1456
|
+
case 0:
|
|
1457
|
+
worker = seeded();
|
|
1458
|
+
expect(worker.imported.some(function (path) { return path.includes('lint-browser'); })).toBe(false);
|
|
1459
|
+
return [4 /*yield*/, worker.dispatch('/SASjsApi/code/lint', runRequest({ code: 'data a;\nrun;\n' }))];
|
|
1460
|
+
case 1:
|
|
1461
|
+
response = _a.sent();
|
|
1462
|
+
expect(response.status).toBe(200);
|
|
1463
|
+
expect(worker.imported.some(function (path) { return path.includes('lint-browser'); })).toBe(true);
|
|
1464
|
+
return [2 /*return*/];
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
}); });
|
|
1468
|
+
/**
|
|
1469
|
+
* A route that resolves a path from the drive is not left with a hole.
|
|
1470
|
+
*
|
|
1471
|
+
* The rules an edited file is linted against are found by walking up from
|
|
1472
|
+
* the file, so the request names neither path. The read that fails says
|
|
1473
|
+
* which piece it needs, and the request is answered again with it - the body
|
|
1474
|
+
* re-read from the copy taken before the first attempt.
|
|
1475
|
+
*/
|
|
1476
|
+
it('fetches the piece a route resolves from the drive, not the one it names', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1477
|
+
var worker, longLine, response, _a, _b, _c;
|
|
1478
|
+
return __generator(this, function (_d) {
|
|
1479
|
+
switch (_d.label) {
|
|
1480
|
+
case 0:
|
|
1481
|
+
;
|
|
1482
|
+
global.__sasjsShards = {
|
|
1483
|
+
index: {
|
|
1484
|
+
format: mockSite_1.DRIVE_FORMAT,
|
|
1485
|
+
sharded: true,
|
|
1486
|
+
boot: 0,
|
|
1487
|
+
entries: {
|
|
1488
|
+
'/sasjs/drive/files/.sasjslint': { shard: 1, bytes: 20 },
|
|
1489
|
+
'/sasjs/drive/files/Public/app/test/prog.sas': {
|
|
1490
|
+
shard: 0,
|
|
1491
|
+
bytes: 12
|
|
1492
|
+
}
|
|
1493
|
+
},
|
|
1494
|
+
shards: [
|
|
1495
|
+
{ id: 0, file: 'host/vfs-snapshot-0.json' },
|
|
1496
|
+
{ id: 1, file: 'host/vfs-snapshot-1.json' }
|
|
1497
|
+
]
|
|
1498
|
+
},
|
|
1499
|
+
0: { '/sasjs/drive/files/Public/app/test/prog.sas': 'data a; run;' },
|
|
1500
|
+
1: {
|
|
1501
|
+
'/sasjs/drive/files/.sasjslint': JSON.stringify({
|
|
1502
|
+
maxLineLength: 200
|
|
1503
|
+
})
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
global.__sasjsShardFetches = [];
|
|
1507
|
+
worker = loadWorker();
|
|
1508
|
+
return [4 /*yield*/, worker.runtime.load({})];
|
|
1509
|
+
case 1:
|
|
1510
|
+
_d.sent();
|
|
1511
|
+
longLine = 'x'.repeat(150);
|
|
1512
|
+
return [4 /*yield*/, worker.dispatch('/SASjsApi/code/lint', runRequest({
|
|
1513
|
+
code: "data _null_;\n".concat(longLine, "\nrun;\n"),
|
|
1514
|
+
filePath: '/Public/app/test/prog.sas'
|
|
1515
|
+
}))];
|
|
1516
|
+
case 2:
|
|
1517
|
+
response = _d.sent();
|
|
1518
|
+
expect(response.status).toBe(200);
|
|
1519
|
+
// the line is over the linter's default 80 and under this drive's 200, so
|
|
1520
|
+
// the answer proves the rules file was read - from a piece the request
|
|
1521
|
+
// never named
|
|
1522
|
+
_a = expect;
|
|
1523
|
+
_c = (_b = JSON).stringify;
|
|
1524
|
+
return [4 /*yield*/, response.json()];
|
|
1525
|
+
case 3:
|
|
1526
|
+
// the line is over the linter's default 80 and under this drive's 200, so
|
|
1527
|
+
// the answer proves the rules file was read - from a piece the request
|
|
1528
|
+
// never named
|
|
1529
|
+
_a.apply(void 0, [_c.apply(_b, [_d.sent()])]).not.toContain('Line exceeds');
|
|
1530
|
+
expect(global.__sasjsShardFetches).toEqual([0, 1]);
|
|
1531
|
+
return [2 /*return*/];
|
|
1532
|
+
}
|
|
1533
|
+
});
|
|
1534
|
+
}); });
|
|
1535
|
+
/**
|
|
1536
|
+
* A request that needs a second piece is answered, body and all.
|
|
1537
|
+
*
|
|
1538
|
+
* The first retry fetches the piece the failed read named; a request that
|
|
1539
|
+
* reads two pieces one after another reaches the second retry, which fetches
|
|
1540
|
+
* the rest. Either way the body has to arrive again - a Request's body can
|
|
1541
|
+
* only be read once, so the first attempt keeps an unread copy and each
|
|
1542
|
+
* attempt after it is made from a copy of that.
|
|
1543
|
+
*/
|
|
1544
|
+
it('replays a request that needs two pieces, with its body intact', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
1545
|
+
var near, far, worker, longLine, response, findings, _a, _b;
|
|
1546
|
+
var _c, _d, _e;
|
|
1547
|
+
return __generator(this, function (_f) {
|
|
1548
|
+
switch (_f.label) {
|
|
1549
|
+
case 0:
|
|
1550
|
+
near = '/sasjs/drive/files/Public/app/test/.sasjslint';
|
|
1551
|
+
far = '/sasjs/drive/files/.sasjslint';
|
|
1552
|
+
global.__sasjsShards = {
|
|
1553
|
+
index: {
|
|
1554
|
+
format: mockSite_1.DRIVE_FORMAT,
|
|
1555
|
+
sharded: true,
|
|
1556
|
+
boot: 0,
|
|
1557
|
+
entries: (_c = {},
|
|
1558
|
+
_c[near] = { shard: 1, bytes: 20 },
|
|
1559
|
+
_c[far] = { shard: 2, bytes: 20 },
|
|
1560
|
+
_c),
|
|
1561
|
+
shards: [
|
|
1562
|
+
{ id: 0, file: 'host/vfs-snapshot-0.json' },
|
|
1563
|
+
{ id: 1, file: 'host/vfs-snapshot-1.json' },
|
|
1564
|
+
{ id: 2, file: 'host/vfs-snapshot-2.json' }
|
|
1565
|
+
]
|
|
1566
|
+
},
|
|
1567
|
+
0: {},
|
|
1568
|
+
// the nearest rules file is unreadable as a config, so the walk goes on
|
|
1569
|
+
// to the drive root's - a second piece, in a second shard
|
|
1570
|
+
1: (_d = {}, _d[near] = 'not json', _d),
|
|
1571
|
+
2: (_e = {}, _e[far] = JSON.stringify({ maxLineLength: 200 }), _e)
|
|
1572
|
+
};
|
|
1573
|
+
global.__sasjsShardFetches = [];
|
|
1574
|
+
worker = loadWorker();
|
|
1575
|
+
return [4 /*yield*/, worker.runtime.load({})];
|
|
1576
|
+
case 1:
|
|
1577
|
+
_f.sent();
|
|
1578
|
+
longLine = 'x'.repeat(150);
|
|
1579
|
+
return [4 /*yield*/, worker.dispatch('/SASjsApi/code/lint', runRequest({
|
|
1580
|
+
code: "data _null_;\n".concat(longLine, "\nrun;\n"),
|
|
1581
|
+
filePath: '/Public/app/test/prog.sas'
|
|
1582
|
+
}))];
|
|
1583
|
+
case 2:
|
|
1584
|
+
response = _f.sent();
|
|
1585
|
+
expect(response.status).toBe(200);
|
|
1586
|
+
_b = (_a = JSON).stringify;
|
|
1587
|
+
return [4 /*yield*/, response.json()];
|
|
1588
|
+
case 3:
|
|
1589
|
+
findings = _b.apply(_a, [_f.sent()]);
|
|
1590
|
+
// the code arrived on the second attempt and the root rules applied: both
|
|
1591
|
+
// halves of the claim
|
|
1592
|
+
expect(findings).toContain('Doxygen');
|
|
1593
|
+
expect(findings).not.toContain('Line exceeds');
|
|
1594
|
+
expect(global.__sasjsShardFetches).toEqual([0, 1, 2]);
|
|
1595
|
+
return [2 /*return*/];
|
|
1596
|
+
}
|
|
1597
|
+
});
|
|
1598
|
+
}); });
|
|
1415
1599
|
/**
|
|
1416
1600
|
* The rules are the drive's, not the linter's defaults.
|
|
1417
1601
|
*
|
|
@@ -2264,6 +2448,119 @@ describe('service worker host', function () {
|
|
|
2264
2448
|
}
|
|
2265
2449
|
});
|
|
2266
2450
|
}); });
|
|
2451
|
+
/**
|
|
2452
|
+
* A sharded drive supplies the piece a request names, and only that one.
|
|
2453
|
+
*
|
|
2454
|
+
* The build splits a captured drive so the first page does not download every
|
|
2455
|
+
* app's bundle. That makes it the host's job to fetch the piece holding the
|
|
2456
|
+
* path a route reads - here a drive file, named in `_filePath` - and to leave
|
|
2457
|
+
* the pieces nobody asked for on the site.
|
|
2458
|
+
*/
|
|
2459
|
+
it('fetches the piece a drive request names, and leaves the rest', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
2460
|
+
var small, large, worker, response, _a;
|
|
2461
|
+
var _b, _c, _d;
|
|
2462
|
+
return __generator(this, function (_e) {
|
|
2463
|
+
switch (_e.label) {
|
|
2464
|
+
case 0:
|
|
2465
|
+
small = '/sasjs/drive/files/config.json';
|
|
2466
|
+
large = '/sasjs/drive/files/data/large.csv';
|
|
2467
|
+
global.__sasjsShards = {
|
|
2468
|
+
index: {
|
|
2469
|
+
format: mockSite_1.DRIVE_FORMAT,
|
|
2470
|
+
sharded: true,
|
|
2471
|
+
boot: 0,
|
|
2472
|
+
entries: (_b = {},
|
|
2473
|
+
_b[small] = { shard: 0, bytes: 2 },
|
|
2474
|
+
_b[large] = { shard: 1, bytes: 64 },
|
|
2475
|
+
_b),
|
|
2476
|
+
shards: [
|
|
2477
|
+
{ id: 0, file: 'host/vfs-snapshot-0.json' },
|
|
2478
|
+
{ id: 1, file: 'host/vfs-snapshot-1.json' }
|
|
2479
|
+
]
|
|
2480
|
+
},
|
|
2481
|
+
0: (_c = {}, _c[small] = '{}', _c),
|
|
2482
|
+
1: (_d = {}, _d[large] = 'a,b\n1,2\n3,4\n', _d)
|
|
2483
|
+
};
|
|
2484
|
+
global.__sasjsShardFetches = [];
|
|
2485
|
+
worker = loadWorker();
|
|
2486
|
+
return [4 /*yield*/, worker.runtime.load({})
|
|
2487
|
+
// the boot shard arrived with the drive; the second piece did not
|
|
2488
|
+
];
|
|
2489
|
+
case 1:
|
|
2490
|
+
_e.sent();
|
|
2491
|
+
// the boot shard arrived with the drive; the second piece did not
|
|
2492
|
+
expect(global.__sasjsShardFetches).toEqual([0]);
|
|
2493
|
+
return [4 /*yield*/, worker.dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/large.csv'))];
|
|
2494
|
+
case 2:
|
|
2495
|
+
response = _e.sent();
|
|
2496
|
+
expect(response.status).toBe(200);
|
|
2497
|
+
_a = expect;
|
|
2498
|
+
return [4 /*yield*/, response.text()];
|
|
2499
|
+
case 3:
|
|
2500
|
+
_a.apply(void 0, [_e.sent()]).toContain('a,b');
|
|
2501
|
+
expect(global.__sasjsShardFetches).toEqual([0, 1]);
|
|
2502
|
+
// a second read of the same piece is answered from memory
|
|
2503
|
+
return [4 /*yield*/, worker.dispatch('/SASjsApi/drive/file?_filePath=' + encodeURIComponent('/data/large.csv'))];
|
|
2504
|
+
case 4:
|
|
2505
|
+
// a second read of the same piece is answered from memory
|
|
2506
|
+
_e.sent();
|
|
2507
|
+
expect(global.__sasjsShardFetches).toEqual([0, 1]);
|
|
2508
|
+
return [2 /*return*/];
|
|
2509
|
+
}
|
|
2510
|
+
});
|
|
2511
|
+
}); });
|
|
2512
|
+
/**
|
|
2513
|
+
* An export is the whole drive, not the part in memory.
|
|
2514
|
+
*
|
|
2515
|
+
* `window.SASjsMock.download()` writes the file a project ships as
|
|
2516
|
+
* `mock-snapshot.json`, so a sharded drive must be fetched before it is
|
|
2517
|
+
* dumped - otherwise the next build ships a snapshot that has quietly lost
|
|
2518
|
+
* every app the visitor never opened.
|
|
2519
|
+
*/
|
|
2520
|
+
it('exports every piece of a sharded drive, not only the loaded ones', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
2521
|
+
var small, large, worker, _a, replies, pending, exported;
|
|
2522
|
+
var _b, _c, _d;
|
|
2523
|
+
return __generator(this, function (_e) {
|
|
2524
|
+
switch (_e.label) {
|
|
2525
|
+
case 0:
|
|
2526
|
+
small = '/sasjs/drive/files/config.json';
|
|
2527
|
+
large = '/sasjs/drive/files/data/large.csv';
|
|
2528
|
+
global.__sasjsShards = {
|
|
2529
|
+
index: {
|
|
2530
|
+
format: mockSite_1.DRIVE_FORMAT,
|
|
2531
|
+
sharded: true,
|
|
2532
|
+
boot: 0,
|
|
2533
|
+
entries: (_b = {},
|
|
2534
|
+
_b[small] = { shard: 0, bytes: 2 },
|
|
2535
|
+
_b[large] = { shard: 1, bytes: 8 },
|
|
2536
|
+
_b),
|
|
2537
|
+
shards: [
|
|
2538
|
+
{ id: 0, file: 'host/vfs-snapshot-0.json' },
|
|
2539
|
+
{ id: 1, file: 'host/vfs-snapshot-1.json' }
|
|
2540
|
+
]
|
|
2541
|
+
},
|
|
2542
|
+
0: (_c = {}, _c[small] = '{}', _c),
|
|
2543
|
+
1: (_d = {}, _d[large] = 'a,b\n1,2\n', _d)
|
|
2544
|
+
};
|
|
2545
|
+
global.__sasjsShardFetches = [];
|
|
2546
|
+
worker = loadWorker();
|
|
2547
|
+
return [4 /*yield*/, worker.runtime.load({})];
|
|
2548
|
+
case 1:
|
|
2549
|
+
_e.sent();
|
|
2550
|
+
_a = worker.message({ type: 'sasjs-snapshot' }), replies = _a.replies, pending = _a.pending;
|
|
2551
|
+
return [4 /*yield*/, pending
|
|
2552
|
+
// the piece nobody read was fetched for the export
|
|
2553
|
+
];
|
|
2554
|
+
case 2:
|
|
2555
|
+
_e.sent();
|
|
2556
|
+
// the piece nobody read was fetched for the export
|
|
2557
|
+
expect(global.__sasjsShardFetches).toEqual([0, 1]);
|
|
2558
|
+
exported = replies.find(function (reply) { return reply.type === 'sasjs-snapshot'; });
|
|
2559
|
+
expect(Object.keys(exported.files).sort()).toEqual([large, small].sort());
|
|
2560
|
+
return [2 /*return*/];
|
|
2561
|
+
}
|
|
2562
|
+
});
|
|
2563
|
+
}); });
|
|
2267
2564
|
it('answers 404 for a drive file the drive does not hold', function () { return __awaiter(void 0, void 0, void 0, function () {
|
|
2268
2565
|
var dispatch, response, _a;
|
|
2269
2566
|
return __generator(this, function (_b) {
|
|
@@ -2835,16 +3132,23 @@ describe('service worker host', function () {
|
|
|
2835
3132
|
expect(seed.replies[0]).toEqual(expect.objectContaining({ type: 'sasjs-seed-ack' }));
|
|
2836
3133
|
runtime.load(files);
|
|
2837
3134
|
snapshot = message({ type: 'sasjs-snapshot' });
|
|
3135
|
+
// the reply waits for the drive to be whole, so a sharded site's export is
|
|
3136
|
+
// not the part of it that happens to be in memory
|
|
3137
|
+
return [4 /*yield*/, snapshot.pending];
|
|
3138
|
+
case 3:
|
|
3139
|
+
// the reply waits for the drive to be whole, so a sharded site's export is
|
|
3140
|
+
// not the part of it that happens to be in memory
|
|
3141
|
+
_c.sent();
|
|
2838
3142
|
expect(snapshot.replies[0].type).toBe('sasjs-snapshot');
|
|
2839
3143
|
expect(Object.keys(snapshot.replies[0].files).length).toBe(1);
|
|
2840
3144
|
reset = message({ type: 'sasjs-reset' });
|
|
2841
3145
|
return [4 /*yield*/, reset.pending];
|
|
2842
|
-
case
|
|
3146
|
+
case 4:
|
|
2843
3147
|
_c.sent();
|
|
2844
3148
|
expect(reset.replies[0]).toEqual(expect.objectContaining({ type: 'sasjs-reset-ack' }));
|
|
2845
3149
|
stats = message({ type: 'sasjs-stats' });
|
|
2846
3150
|
return [4 /*yield*/, stats.pending];
|
|
2847
|
-
case
|
|
3151
|
+
case 5:
|
|
2848
3152
|
_c.sent();
|
|
2849
3153
|
expect(stats.replies[0]).toEqual(expect.objectContaining({ type: 'sasjs-stats' }));
|
|
2850
3154
|
return [2 /*return*/];
|
package/package.json
CHANGED